import ManaAuthUI import ManaCore import SwiftData import SwiftUI // swiftlint:disable type_body_length /// Detail-View für ein Public-Deck. Subscribe-Button löst Auto-Fork /// serverseitig aus und navigiert anschließend zur eigenen Deck-Detail. /// Toolbar-Menu („…") hostet Report + Block-Author (App-Review-Pflicht). struct PublicDeckView: View { let slug: String @Environment(AuthClient.self) private var auth @Environment(ManaAuthGate.self) private var authGate @Environment(\.modelContext) private var context @State private var detail: PublicDeckDetail? @State private var isLoading = false @State private var isSubscribing = false @State private var errorMessage: String? @State private var subscribed: SubscribeResponse? // Moderation-State @State private var showReportSheet = false @State private var showBlockConfirm = false @State private var moderationToast: String? var body: some View { ZStack { WordeckTheme.background.ignoresSafeArea() content } .navigationTitle(detail?.deck.title ?? "Deck") #if os(iOS) .navigationBarTitleDisplayMode(.inline) #endif .toolbar { if detail != nil { ToolbarItem(placement: .topBarTrailing) { moderationMenu } } } .task(id: slug) { await load() } .sheet(isPresented: $showReportSheet) { NavigationStack { ReportDeckSheet(slug: slug) { message in moderationToast = message } } } .confirmationDialog( "Author blockieren?", isPresented: $showBlockConfirm, titleVisibility: .visible, presenting: detail?.owner ) { owner in Button("\(owner.displayName) blockieren", role: .destructive) { Task { await blockAuthor(slug: owner.slug, name: owner.displayName) } } Button("Abbrechen", role: .cancel) {} } message: { _ in Text("Decks dieses Authors erscheinen für dich nicht mehr im Marketplace.") } .overlay(alignment: .top) { if let toast = moderationToast { ToastBanner(text: toast) .padding(.top, 8) .task { try? await Task.sleep(for: .seconds(3)) moderationToast = nil } } } } private var moderationMenu: some View { Menu { Button { authGate.require(reason: "marketplace-report") { showReportSheet = true } } label: { Label("Deck melden …", systemImage: "flag") } if let owner = detail?.owner { Button(role: .destructive) { authGate.require(reason: "marketplace-block") { showBlockConfirm = true } } label: { Label("\(owner.displayName) blockieren", systemImage: "hand.raised") } } } label: { Image(systemName: "ellipsis.circle") } } @ViewBuilder private var content: some View { if isLoading, detail == nil { ProgressView() .tint(WordeckTheme.primary) } else if let detail { ScrollView { VStack(alignment: .leading, spacing: 16) { header(detail: detail) Divider().background(WordeckTheme.border) metadata(detail: detail) Divider().background(WordeckTheme.border) subscribeSection(detail: detail) if let errorMessage { Text(errorMessage) .font(.caption) .foregroundStyle(WordeckTheme.error) .padding(.horizontal, 16) } } .padding(.vertical, 16) } } else if let errorMessage { ContentUnavailableView( "Deck nicht gefunden", systemImage: "questionmark.folder", description: Text(errorMessage) ) .foregroundStyle(WordeckTheme.foreground) } } private func header(detail: PublicDeckDetail) -> some View { VStack(alignment: .leading, spacing: 8) { HStack { Text(detail.deck.title) .font(.title.bold()) .foregroundStyle(WordeckTheme.foreground) if detail.deck.isFeatured { Image(systemName: "star.fill") .foregroundStyle(WordeckTheme.warning) } } if let description = detail.deck.description, !description.isEmpty { Text(description) .foregroundStyle(WordeckTheme.mutedForeground) } } .padding(.horizontal, 16) } private func metadata(detail: PublicDeckDetail) -> some View { VStack(alignment: .leading, spacing: 8) { if let owner = detail.owner { HStack(spacing: 6) { Image(systemName: "person.crop.circle") .foregroundStyle(WordeckTheme.mutedForeground) Text(owner.displayName) .foregroundStyle(WordeckTheme.foreground) if owner.verifiedMana { Image(systemName: "checkmark.seal.fill") .font(.caption) .foregroundStyle(WordeckTheme.primary) } } .font(.subheadline) } HStack(spacing: 16) { if let version = detail.latestVersion { Label("v\(version.semver)", systemImage: "tag") Label("\(version.cardCount) Karten", systemImage: "rectangle.stack") } Label(detail.deck.license, systemImage: "doc.text") if let language = detail.deck.language { Label(language.uppercased(), systemImage: "globe") } } .font(.caption) .foregroundStyle(WordeckTheme.mutedForeground) if let changelog = detail.latestVersion?.changelog, !changelog.isEmpty { Text("Changelog") .font(.caption.weight(.semibold)) .foregroundStyle(WordeckTheme.mutedForeground) .padding(.top, 8) Text(changelog) .font(.caption) .foregroundStyle(WordeckTheme.foreground) } } .padding(.horizontal, 16) } private func subscribeSection(detail: PublicDeckDetail) -> some View { VStack(spacing: 12) { if let subscribed { Label("Abonniert — dein Fork ist in deiner Bibliothek", systemImage: "checkmark.circle.fill") .foregroundStyle(WordeckTheme.success) .padding() .frame(maxWidth: .infinity) .background(WordeckTheme.success.opacity(0.1), in: RoundedRectangle(cornerRadius: 10)) NavigationLink(value: subscribed.privateDeckId) { Label("Zum eigenen Deck", systemImage: "arrow.right.circle") .frame(maxWidth: .infinity) .padding(.vertical, 10) .background(WordeckTheme.primary, in: RoundedRectangle(cornerRadius: 10)) .foregroundStyle(WordeckTheme.primaryForeground) } .buttonStyle(.plain) } else if detail.deck.isTakedown { Label("Dieses Deck wurde entfernt", systemImage: "exclamationmark.triangle") .foregroundStyle(WordeckTheme.error) } else if detail.deck.latestVersionId == nil { Label("Noch keine Version veröffentlicht", systemImage: "clock") .foregroundStyle(WordeckTheme.mutedForeground) } else { Button { authGate.require(reason: "marketplace-subscribe") { Task { await subscribe(detail: detail) } } } label: { HStack { if isSubscribing { ProgressView() .controlSize(.small) .tint(WordeckTheme.primaryForeground) } Text(detail.deck.priceCredits > 0 ? "Abonnieren (\(detail.deck.priceCredits) Credits)" : "Abonnieren") .fontWeight(.semibold) } .frame(maxWidth: .infinity) .padding(.vertical, 14) .background(WordeckTheme.primary, in: RoundedRectangle(cornerRadius: 10)) .foregroundStyle(WordeckTheme.primaryForeground) } .buttonStyle(.plain) .disabled(isSubscribing) } } .padding(.horizontal, 16) } private func load() async { isLoading = true defer { isLoading = false } let api = WordeckAPI(auth: auth) do { detail = try await api.publicDeck(slug: slug) } catch { errorMessage = (error as? LocalizedError)?.errorDescription ?? String(describing: error) } } private func blockAuthor(slug: String, name: String) async { let api = WordeckAPI(auth: auth) do { try await api.blockAuthor(slug: slug) moderationToast = "\(name) blockiert." } catch { moderationToast = "Blockieren fehlgeschlagen: \(error.localizedDescription)" } } private func subscribe(detail _: PublicDeckDetail) async { isSubscribing = true errorMessage = nil defer { isSubscribing = false } let api = WordeckAPI(auth: auth) do { let response = try await api.subscribe(slug: slug) subscribed = response // Cache neu laden, damit der Fork in der Liste auftaucht let store = DeckListStore(auth: auth, context: context) await store.refresh() } catch { errorMessage = (error as? LocalizedError)?.errorDescription ?? String(describing: error) } } } // swiftlint:enable type_body_length