import ManaCore import SwiftUI // swiftlint:disable file_length // swiftlint:disable type_body_length /// Publish eines privaten Decks in den Wordeck-Marketplace. /// /// Modi: Erst-Publish (mit Author-Setup + Init + Publish 1.0.0) oder /// neue Version eines existierenden Marketplace-Decks (Auto-Semver-Bump). /// Image-Occlusion- und Audio-Front-Karten werden übersprungen — der /// Server hat heute keinen Marketplace-Media-Re-Upload-Flow. /// /// `type_body_length` ist bewusst übersprungen — Publish-Flow ist eine /// zusammenhängende State-Maschine (Author → Init → Publish). struct MarketplacePublishView: View { enum PublishMode: Hashable { case firstPublish case newVersion(slug: String) } let privateDeck: CachedDeck let onPublished: (MarketplacePublishResponse) -> Void @Environment(AuthClient.self) private var auth @Environment(\.dismiss) private var dismiss // Publish-Mode @State private var publishMode: PublishMode = .firstPublish @State private var ownedDecks: [OwnedMarketplaceDeck] = [] @State private var selectedExistingSlug: String? // Author-Profil-State @State private var hasAuthor: Bool? @State private var authorSlug: String = "" @State private var authorDisplayName: String = "" @State private var authorBio: String = "" @State private var authorPseudonym: Bool = false // Deck-Metadaten @State private var slug: String = "" @State private var title: String = "" @State private var deckDescription: String = "" @State private var language: GenerationLanguage = .de @State private var license: MarketplaceLicense = .personalUse @State private var priceCredits: Int = 0 @State private var category: DeckCategory? // Version-Metadaten @State private var semver: String = "1.0.0" @State private var changelog: String = "" // Submit-State @State private var isSubmitting = false @State private var errorMessage: String? @State private var result: MarketplacePublishResponse? @State private var skippedCardCount: Int = 0 var body: some View { Form { if !ownedDecks.isEmpty { publishModeSection } if isFirstPublish, hasAuthor == false { authorSection } if isFirstPublish { deckMetadataSection licenseSection categorySection } else if let existing = currentExistingDeck { existingDeckInfoSection(deck: existing) } versionSection if skippedCardCount > 0 { skippedNoteSection } if let errorMessage { Section { Text(errorMessage) .font(.footnote) .foregroundStyle(WordeckTheme.error) } } } .disabled(isSubmitting) .navigationTitle("Im Marketplace veröffentlichen") #if os(iOS) .navigationBarTitleDisplayMode(.inline) #endif .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Abbrechen") { dismiss() } } ToolbarItem(placement: .confirmationAction) { Button("Veröffentlichen") { Task { await submit() } } .disabled(!canSubmit || isSubmitting) } } .overlay { if isSubmitting { publishProgressOverlay } } .alert(item: $result) { response in Alert( title: Text("Veröffentlicht: \(response.deck.title)"), message: Text(alertMessage(for: response)), dismissButton: .default(Text("OK")) { onPublished(response) dismiss() } ) } .task { await prefill() } } private var isFirstPublish: Bool { if case .firstPublish = publishMode { return true } return false } private var currentExistingDeck: OwnedMarketplaceDeck? { guard let slug = selectedExistingSlug else { return nil } return ownedDecks.first { $0.slug == slug } } private var publishModeSection: some View { Section { Picker("Modus", selection: $publishMode) { Text("Neues Marketplace-Deck").tag(PublishMode.firstPublish) ForEach(ownedDecks) { deck in Text("Neue Version: \(deck.title)") .tag(PublishMode.newVersion(slug: deck.slug)) } } .pickerStyle(.menu) .onChange(of: publishMode) { _, newMode in applyPublishMode(newMode) } } header: { Text("Veröffentlichungs-Modus") } footer: { Text("Du hast schon Decks im Marketplace. Wähle eine, um eine neue Version zu publishen.") } } private func existingDeckInfoSection(deck: OwnedMarketplaceDeck) -> some View { Section { LabeledContent("Slug", value: deck.slug) LabeledContent("Titel", value: deck.title) if let latest = deck.latestVersion { LabeledContent("Aktuelle Version", value: "v\(latest.semver) · \(latest.cardCount) Karten") } else { LabeledContent("Aktuelle Version", value: "—") } } header: { Text("Bestehendes Deck") } footer: { Text("Metadaten ändern: Marketplace-Webansicht → Deck → Bearbeiten.") } } private var authorSection: some View { Section { TextField("Author-Slug (URL)", text: $authorSlug) #if os(iOS) .textInputAutocapitalization(.never) #endif .autocorrectionDisabled(true) TextField("Anzeigename", text: $authorDisplayName) TextField("Bio (optional)", text: $authorBio, axis: .vertical) .lineLimit(2 ... 4) Toggle("Pseudonym-Modus", isOn: $authorPseudonym) } header: { Text("Author-Profil anlegen") } footer: { Text("Pflicht-Schritt vor dem ersten Marketplace-Deck. Slug erscheint in Marketplace-URLs.") } } private var deckMetadataSection: some View { Section { TextField("Slug (URL)", text: $slug) #if os(iOS) .textInputAutocapitalization(.never) #endif .autocorrectionDisabled(true) TextField("Titel", text: $title) #if os(iOS) .textInputAutocapitalization(.sentences) #endif TextField("Beschreibung", text: $deckDescription, axis: .vertical) .lineLimit(2 ... 6) Picker("Sprache", selection: $language) { ForEach(GenerationLanguage.allCases, id: \.self) { lang in Text(lang.label).tag(lang) } } .pickerStyle(.segmented) } header: { Text("Deck-Metadaten") } footer: { Text("Der Slug wird Teil der Marketplace-URL: wordeck.com/d/.") } } private var licenseSection: some View { Section("Lizenz") { Picker("Lizenz", selection: $license) { ForEach(MarketplaceLicense.allCases, id: \.self) { lic in Text(lic.label).tag(lic) } } if license == .proOnly { Stepper(value: $priceCredits, in: 0 ... 100_000, step: 10) { Text("Preis: \(priceCredits) Credits") } } } } private var categorySection: some View { Section("Kategorie") { Picker("Kategorie", selection: $category) { Text("Keine").tag(DeckCategory?.none) ForEach(DeckCategory.allCases, id: \.self) { cat in Text(cat.label).tag(DeckCategory?.some(cat)) } } } } private var versionSection: some View { Section { TextField("SemVer (z.B. 1.0.0)", text: $semver) #if os(iOS) .textInputAutocapitalization(.never) #endif .autocorrectionDisabled(true) #if os(iOS) .keyboardType(.numbersAndPunctuation) #endif TextField("Changelog (optional)", text: $changelog, axis: .vertical) .lineLimit(2 ... 4) } header: { Text("Version") } footer: { Text("Erst-Publish: 1.0.0. Spätere Versionen müssen semver-größer sein.") } } private var skippedNoteSection: some View { Section { Label( """ \(skippedCardCount) Karten werden übersprungen — Bild-\ Verdeckung und Audio brauchen Marketplace-Media-Upload. """, systemImage: "info.circle" ) .font(.caption) .foregroundStyle(WordeckTheme.mutedForeground) } } private var publishProgressOverlay: some View { ZStack { Color.black.opacity(0.55).ignoresSafeArea() VStack(spacing: 12) { ProgressView().controlSize(.large).tint(WordeckTheme.primary) Text("Wird veröffentlicht …") .font(.headline) .foregroundStyle(WordeckTheme.foreground) Text("AI-Moderation läuft — kann ein paar Sekunden dauern.") .font(.caption) .foregroundStyle(WordeckTheme.mutedForeground) .multilineTextAlignment(.center) } .padding(24) .frame(maxWidth: 320) .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) } } private var canSubmit: Bool { let semverOK = semver.range(of: "^\\d+\\.\\d+\\.\\d+$", options: .regularExpression) != nil guard semverOK else { return false } switch publishMode { case .firstPublish: if hasAuthor == false { guard authorDisplayName.trimmed.count >= 1 else { return false } guard authorSlug.trimmed.count >= 3 else { return false } } return slug.trimmed.count >= 3 && !title.trimmed.isEmpty case .newVersion: return selectedExistingSlug != nil } } private func prefill() async { title = privateDeck.name deckDescription = privateDeck.deckDescription ?? "" category = privateDeck.category slug = slugify(privateDeck.name) let api = WordeckAPI(auth: auth) async let authorState = api.myAuthor() async let ownedState = api.myMarketplaceDecks() do { hasAuthor = try await authorState } catch { hasAuthor = false errorMessage = "Author-Profil konnte nicht geladen werden: \(error.localizedDescription)" } ownedDecks = await (try? ownedState) ?? [] } /// State-Übergang beim Wechsel des Publish-Modus. /// - Erst-Publish: Slug aus dem privaten Deck-Namen, Semver 1.0.0. /// - Neue Version: Slug-Feld unbenutzt (Server kennt Slug), /// Semver-Default = Bump der aktuellen Version. private func applyPublishMode(_ mode: PublishMode) { switch mode { case .firstPublish: selectedExistingSlug = nil semver = "1.0.0" case let .newVersion(existingSlug): selectedExistingSlug = existingSlug if let latest = ownedDecks.first(where: { $0.slug == existingSlug })?.latestVersion { semver = bumpMinor(latest.semver) } else { semver = "1.0.0" } } } /// `1.4.2` → `1.5.0`. Bei unparsbarem Input bleibt 1.0.0 als Default. private func bumpMinor(_ version: String) -> String { let parts = version.split(separator: ".") guard parts.count == 3, let major = Int(parts[0]), let minor = Int(parts[1]) else { return "1.0.0" } return "\(major).\(minor + 1).0" } private func submit() async { isSubmitting = true errorMessage = nil defer { isSubmitting = false } let api = WordeckAPI(auth: auth) do { let targetSlug = try await prepareTargetSlug(api: api) try await publishCards(toSlug: targetSlug, api: api) } catch let error as AuthError { errorMessage = mapPublishError(error) } catch { errorMessage = error.localizedDescription } } /// Erst-Publish-Pfad: Author-Profil + Marketplace-Deck-Init. /// Liefert den Slug auf den `publishCards` veröffentlicht. private func prepareTargetSlug(api: WordeckAPI) async throws -> String { switch publishMode { case .firstPublish: if hasAuthor == false { try await api.upsertAuthor(AuthorUpsertBody( slug: authorSlug.trimmed, displayName: authorDisplayName.trimmed, bio: authorBio.trimmed.isEmpty ? nil : authorBio.trimmed, avatarUrl: nil, pseudonym: authorPseudonym )) hasAuthor = true } _ = try await api.initMarketplaceDeck(MarketplaceDeckInitBody( slug: slug.trimmed, title: title.trimmed, description: deckDescription.trimmed.isEmpty ? nil : deckDescription.trimmed, language: language.rawValue, license: license.rawValue, priceCredits: license == .proOnly ? priceCredits : 0, category: category )) return slug.trimmed case let .newVersion(existingSlug): return existingSlug } } /// Lädt alle Karten des privaten Decks, konvertiert in Marketplace- /// Format und veröffentlicht die neue Version. private func publishCards(toSlug targetSlug: String, api: WordeckAPI) async throws { let cards = try await api.listCards(deckId: privateDeck.id) let converted = cards.compactMap(MarketplaceCardConverter.convert) skippedCardCount = cards.count - converted.count guard !converted.isEmpty else { errorMessage = "Keine Karten kompatibel mit dem Marketplace-Format." return } result = try await api.publishMarketplaceVersion( slug: targetSlug, body: MarketplacePublishBody( semver: semver.trimmed, changelog: changelog.trimmed.isEmpty ? nil : changelog.trimmed, cards: converted ) ) } private func mapPublishError(_ error: AuthError) -> String { if case let .serverError(status, _, message) = error { switch status { case 409: if let message, message.contains("slug_taken") { return "Dieser Slug ist schon vergeben. Bitte einen anderen wählen." } return message ?? "Konflikt — Version-Bump nötig?" case 403: if let message, message.contains("moderation_block") { return "AI-Moderation hat den Inhalt blockiert." } return message ?? "Aktion nicht erlaubt." case 422: return message ?? "Eingabe ungültig." default: break } } return error.errorDescription ?? "Veröffentlichen fehlgeschlagen." } private func alertMessage(for response: MarketplacePublishResponse) -> String { let parts = [ "Version \(response.version.semver)", "\(response.version.cardCount) Karten", skippedCardCount > 0 ? "\(skippedCardCount) übersprungen" : nil, "Moderation: \(response.moderation.verdict)" ].compactMap(\.self) return parts.joined(separator: " · ") } private func slugify(_ input: String) -> String { let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyz0123456789-") let lowered = input .folding(options: .diacriticInsensitive, locale: .current) .lowercased() var result = "" for scalar in lowered.unicodeScalars { if allowed.contains(scalar) { result.unicodeScalars.append(scalar) } else { result.append("-") } } while result.hasPrefix("-") { result.removeFirst() } while result.hasSuffix("-") { result.removeLast() } while result.contains("--") { result = result.replacingOccurrences(of: "--", with: "-") } return String(result.prefix(60)) } } // swiftlint:enable type_body_length private extension String { var trimmed: String { trimmingCharacters(in: .whitespacesAndNewlines) } } extension MarketplacePublishResponse: Identifiable { var id: String { version.id } }