431 lines
16 KiB
Swift
431 lines
16 KiB
Swift
import SwiftUI
|
|
|
|
// MARK: - Edit History View
|
|
|
|
struct EditHistoryView: View {
|
|
@State private var entries: [EditHistoryEntry] = []
|
|
@State private var isLoading = true
|
|
@State private var errorMessage: String?
|
|
@State private var revertingId: String?
|
|
@State private var confirmRevert: EditHistoryEntry?
|
|
@State private var undoResult: (restored: Int, failed: Int)?
|
|
@State private var undoError: String?
|
|
|
|
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
|
|
private let api = CompanionAPIService.shared
|
|
|
|
var body: some View {
|
|
List {
|
|
if isLoading {
|
|
Section {
|
|
HStack {
|
|
ProgressView().tint(accentPink)
|
|
Text("Loading edit history…")
|
|
.font(.system(size: 14))
|
|
.foregroundColor(.gray)
|
|
.padding(.leading, 8)
|
|
}
|
|
.padding(.vertical, 4)
|
|
}
|
|
} else if let error = errorMessage {
|
|
Section {
|
|
Text(error)
|
|
.font(.system(size: 13))
|
|
.foregroundColor(.red)
|
|
}
|
|
} else if entries.isEmpty {
|
|
Section {
|
|
HStack(spacing: 12) {
|
|
Image(systemName: "clock.arrow.circlepath")
|
|
.font(.system(size: 28))
|
|
.foregroundColor(.gray)
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text("No edit history")
|
|
.font(.system(size: 15, weight: .medium))
|
|
Text("Tag edits will appear here with the option to revert.")
|
|
.font(.system(size: 13))
|
|
.foregroundColor(.gray)
|
|
}
|
|
}
|
|
.padding(.vertical, 8)
|
|
}
|
|
} else {
|
|
// Group entries by section date
|
|
ForEach(sectionDates, id: \.self) { section in
|
|
Section(header: Text(section).font(.system(size: 13, weight: .semibold))) {
|
|
ForEach(entriesForSection(section)) { entry in
|
|
EditHistoryCard(
|
|
entry: entry,
|
|
isReverting: revertingId == entry.batch_id,
|
|
onRevert: { confirmRevert = entry }
|
|
)
|
|
.listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.listStyle(.insetGrouped)
|
|
.navigationTitle("Edit History")
|
|
.toolbar {
|
|
ToolbarItem(placement: .topBarTrailing) {
|
|
Button(action: { Task { await loadHistory() } }) {
|
|
Image(systemName: "arrow.clockwise")
|
|
.font(.system(size: 14, weight: .semibold))
|
|
.foregroundColor(accentPink)
|
|
}
|
|
}
|
|
}
|
|
.task { await loadHistory() }
|
|
.confirmationDialog(
|
|
"Revert this edit?",
|
|
isPresented: Binding(
|
|
get: { confirmRevert != nil },
|
|
set: { if !$0 { confirmRevert = nil } }
|
|
),
|
|
titleVisibility: .visible
|
|
) {
|
|
if let entry = confirmRevert {
|
|
Button("Revert \(entry.file_count) track\(entry.file_count == 1 ? "" : "s")", role: .destructive) {
|
|
Task { await performRevert(entry) }
|
|
}
|
|
Button("Cancel", role: .cancel) { confirmRevert = nil }
|
|
}
|
|
} message: {
|
|
if let entry = confirmRevert {
|
|
Text("This will restore all \(entry.file_count) track\(entry.file_count == 1 ? "" : "s") to their previous tags. The files will be updated on disk and Navidrome will rescan.")
|
|
}
|
|
}
|
|
.overlay(alignment: .bottom) {
|
|
// Undo result toast
|
|
if let result = undoResult {
|
|
UndoResultToast(
|
|
restored: result.restored,
|
|
failed: result.failed,
|
|
onDismiss: { withAnimation { undoResult = nil } }
|
|
)
|
|
.transition(.move(edge: .bottom).combined(with: .opacity))
|
|
.padding(.bottom, 16)
|
|
}
|
|
if let error = undoError {
|
|
UndoErrorToast(
|
|
message: error,
|
|
onDismiss: { withAnimation { undoError = nil } }
|
|
)
|
|
.transition(.move(edge: .bottom).combined(with: .opacity))
|
|
.padding(.bottom, 16)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Section grouping
|
|
|
|
private var sectionDates: [String] {
|
|
var seen = Set<String>()
|
|
var result: [String] = []
|
|
for e in entries {
|
|
let s = e.sectionDate
|
|
if seen.insert(s).inserted {
|
|
result.append(s)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func entriesForSection(_ section: String) -> [EditHistoryEntry] {
|
|
entries.filter { $0.sectionDate == section }
|
|
}
|
|
|
|
// MARK: - API
|
|
|
|
private func loadHistory() async {
|
|
isLoading = true
|
|
errorMessage = nil
|
|
do {
|
|
entries = try await api.fetchEditHistory()
|
|
} catch {
|
|
errorMessage = error.localizedDescription
|
|
}
|
|
isLoading = false
|
|
}
|
|
|
|
private func performRevert(_ entry: EditHistoryEntry) async {
|
|
revertingId = entry.batch_id
|
|
do {
|
|
let result = try await api.undoBatchEdit(batchId: entry.batch_id)
|
|
let restored = result.restored?.count ?? 0
|
|
let failed = result.failed?.count ?? 0
|
|
withAnimation { undoResult = (restored, failed) }
|
|
// Refresh the list so the entry shows as reverted
|
|
await loadHistory()
|
|
// Auto-dismiss after 5s
|
|
Task {
|
|
try? await Task.sleep(for: .seconds(5))
|
|
withAnimation { undoResult = nil }
|
|
}
|
|
} catch {
|
|
withAnimation { undoError = error.localizedDescription }
|
|
Task {
|
|
try? await Task.sleep(for: .seconds(5))
|
|
withAnimation { undoError = nil }
|
|
}
|
|
}
|
|
revertingId = nil
|
|
}
|
|
}
|
|
|
|
// MARK: - Edit History Card
|
|
|
|
private struct EditHistoryCard: View {
|
|
let entry: EditHistoryEntry
|
|
let isReverting: Bool
|
|
let onRevert: () -> Void
|
|
|
|
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
|
|
private var isReverted: Bool { entry.is_reverted ?? false }
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
// Top row: icon + title + time
|
|
HStack(alignment: .top, spacing: 10) {
|
|
editIcon
|
|
.frame(width: 34, height: 34)
|
|
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
HStack {
|
|
Text(entry.editTitle)
|
|
.font(.system(size: 15, weight: .semibold))
|
|
.foregroundColor(isReverted ? .gray : .white)
|
|
|
|
if entry.edit_type == "single" {
|
|
Text("single")
|
|
.font(.system(size: 10, weight: .medium))
|
|
.foregroundColor(.gray)
|
|
.padding(.horizontal, 5)
|
|
.padding(.vertical, 1)
|
|
.background(Color.white.opacity(0.06))
|
|
.clipShape(Capsule())
|
|
}
|
|
}
|
|
|
|
Text("\(entry.file_count) track\(entry.file_count == 1 ? "" : "s") · \(entry.contextString)")
|
|
.font(.system(size: 13))
|
|
.foregroundColor(.gray)
|
|
.lineLimit(1)
|
|
}
|
|
|
|
Spacer(minLength: 0)
|
|
|
|
Text(entry.timeAgo)
|
|
.font(.system(size: 12))
|
|
.foregroundColor(Color(white: 0.4))
|
|
}
|
|
|
|
// Change pills
|
|
if let changes = entry.tags_changed, !changes.isEmpty {
|
|
changePills(changes)
|
|
}
|
|
|
|
// Action row
|
|
HStack {
|
|
// Affected context
|
|
if let albums = entry.affected_albums, !albums.isEmpty {
|
|
Text(albums.prefix(2).joined(separator: ", "))
|
|
.font(.system(size: 12))
|
|
.foregroundColor(Color(white: 0.35))
|
|
.lineLimit(1)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
if isReverting {
|
|
ProgressView()
|
|
.tint(accentPink)
|
|
.scaleEffect(0.8)
|
|
} else if isReverted {
|
|
HStack(spacing: 4) {
|
|
Image(systemName: "arrow.uturn.backward")
|
|
.font(.system(size: 11))
|
|
Text("Reverted")
|
|
.font(.system(size: 13, weight: .semibold))
|
|
}
|
|
.foregroundColor(.green.opacity(0.7))
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 5)
|
|
.background(Color.green.opacity(0.1))
|
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
|
} else {
|
|
Button(action: onRevert) {
|
|
HStack(spacing: 4) {
|
|
Image(systemName: "arrow.uturn.backward")
|
|
.font(.system(size: 11))
|
|
Text("Revert")
|
|
.font(.system(size: 13, weight: .semibold))
|
|
}
|
|
.foregroundColor(accentPink)
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 5)
|
|
.background(accentPink.opacity(0.1))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 8)
|
|
.stroke(accentPink.opacity(0.25), lineWidth: 1)
|
|
)
|
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
}
|
|
.padding(.vertical, 4)
|
|
.opacity(isReverted ? 0.5 : 1.0)
|
|
}
|
|
|
|
// MARK: - Icon
|
|
|
|
@ViewBuilder
|
|
private var editIcon: some View {
|
|
let changes = entry.tags_changed ?? [:]
|
|
let keys = Set(changes.keys)
|
|
|
|
if keys.count == 1, keys.contains("genre") {
|
|
iconBox(symbol: "music.note", bgColor: .purple)
|
|
} else if keys.count == 1, keys.contains("album") {
|
|
iconBox(symbol: "square.stack", bgColor: .blue)
|
|
} else if keys.count == 1, (keys.contains("artist") || keys.contains("album_artist")) {
|
|
iconBox(symbol: "person.fill", bgColor: .orange)
|
|
} else if keys.count > 1 {
|
|
iconBox(symbol: "pencil", bgColor: accentPink)
|
|
} else {
|
|
iconBox(symbol: "tag.fill", bgColor: .gray)
|
|
}
|
|
}
|
|
|
|
private func iconBox(symbol: String, bgColor: Color) -> some View {
|
|
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
|
.fill(bgColor.opacity(0.15))
|
|
.overlay(
|
|
Image(systemName: symbol)
|
|
.font(.system(size: 15, weight: .medium))
|
|
.foregroundColor(bgColor)
|
|
)
|
|
}
|
|
|
|
// MARK: - Change pills
|
|
|
|
private func changePills(_ changes: [String: String]) -> some View {
|
|
let sorted = changes.sorted { $0.key < $1.key }
|
|
return FlowLayout(spacing: 6) {
|
|
ForEach(sorted, id: \.key) { key, value in
|
|
HStack(spacing: 3) {
|
|
Text(key.capitalized)
|
|
.font(.system(size: 11, weight: .medium))
|
|
Text("→")
|
|
.font(.system(size: 10))
|
|
.opacity(0.5)
|
|
Text(value)
|
|
.font(.system(size: 11, weight: .medium))
|
|
.lineLimit(1)
|
|
}
|
|
.foregroundColor(pillColor(for: key))
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 3)
|
|
.background(pillColor(for: key).opacity(0.1))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 6)
|
|
.stroke(pillColor(for: key).opacity(0.2), lineWidth: 1)
|
|
)
|
|
.clipShape(RoundedRectangle(cornerRadius: 6))
|
|
}
|
|
}
|
|
}
|
|
|
|
private func pillColor(for key: String) -> Color {
|
|
switch key.lowercased() {
|
|
case "genre": return .purple
|
|
case "album": return .blue
|
|
case "artist", "album_artist": return .orange
|
|
case "year", "date": return .green
|
|
case "title": return .cyan
|
|
default: return .gray
|
|
}
|
|
}
|
|
}
|
|
|
|
// FlowLayout is defined in LyricsOverlayView.swift — reused here for change pills.
|
|
|
|
// MARK: - Undo Result Toast
|
|
|
|
private struct UndoResultToast: View {
|
|
let restored: Int
|
|
let failed: Int
|
|
let onDismiss: () -> Void
|
|
|
|
var body: some View {
|
|
HStack(spacing: 12) {
|
|
Image(systemName: failed == 0 ? "checkmark.circle.fill" : "exclamationmark.triangle.fill")
|
|
.font(.system(size: 20))
|
|
.foregroundColor(failed == 0 ? .green : .yellow)
|
|
|
|
VStack(alignment: .leading, spacing: 1) {
|
|
Text(failed == 0
|
|
? "Reverted \(restored) track\(restored == 1 ? "" : "s")"
|
|
: "\(restored) reverted, \(failed) failed")
|
|
.font(.system(size: 14, weight: .semibold))
|
|
.foregroundColor(.white)
|
|
Text("Tags restored to previous state")
|
|
.font(.system(size: 12))
|
|
.foregroundColor(.gray)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
Button(action: onDismiss) {
|
|
Image(systemName: "xmark")
|
|
.font(.system(size: 12, weight: .bold))
|
|
.foregroundColor(.gray)
|
|
}
|
|
}
|
|
.padding(14)
|
|
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 14))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 14)
|
|
.stroke(Color.white.opacity(0.08), lineWidth: 1)
|
|
)
|
|
.padding(.horizontal, 16)
|
|
}
|
|
}
|
|
|
|
// MARK: - Undo Error Toast
|
|
|
|
private struct UndoErrorToast: View {
|
|
let message: String
|
|
let onDismiss: () -> Void
|
|
|
|
var body: some View {
|
|
HStack(spacing: 12) {
|
|
Image(systemName: "xmark.circle.fill")
|
|
.font(.system(size: 20))
|
|
.foregroundColor(.red)
|
|
|
|
Text(message)
|
|
.font(.system(size: 13, weight: .medium))
|
|
.foregroundColor(.white)
|
|
.lineLimit(2)
|
|
|
|
Spacer()
|
|
|
|
Button(action: onDismiss) {
|
|
Image(systemName: "xmark")
|
|
.font(.system(size: 12, weight: .bold))
|
|
.foregroundColor(.gray)
|
|
}
|
|
}
|
|
.padding(14)
|
|
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 14))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 14)
|
|
.stroke(Color.red.opacity(0.2), lineWidth: 1)
|
|
)
|
|
.padding(.horizontal, 16)
|
|
}
|
|
}
|