feat: Long-Press-Kontextmenüs für Decks, Karten + Marktplatz

DeckStackTile: Bearbeiten, Duplizieren, Löschen. CardPreviewRow:
Vorder-/Rückseite kopieren, Löschen. BrowseRow (Marktplatz): Link
kopieren + teilen. Neuer geteilter PlatformClipboard-Helfer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-25 13:24:28 +02:00
parent f9392303da
commit d809658e5f
4 changed files with 105 additions and 0 deletions

View file

@ -0,0 +1,20 @@
import SwiftUI
#if canImport(UIKit)
import UIKit
#elseif canImport(AppKit)
import AppKit
#endif
/// Plattform-übergreifendes Kopieren in die Zwischenablage (iOS
/// `UIPasteboard`, macOS `NSPasteboard`). Geteilt von den Listen-
/// Kontextmenüs (Karten-Vorder-/Rückseite, Marktplatz-Link).
enum PlatformClipboard {
static func copy(_ text: String) {
#if canImport(UIKit)
UIPasteboard.general.string = text
#elseif canImport(AppKit)
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(text, forType: .string)
#endif
}
}

View file

@ -298,6 +298,28 @@ struct DeckDetailView: View {
}
.buttonStyle(.plain)
.accessibilityHint("Tippen zum Bearbeiten")
.contextMenu {
if let front = card.fields["front"] ?? card.fields["text"], !front.isEmpty {
Button {
PlatformClipboard.copy(front)
} label: {
Label("Vorderseite kopieren", systemImage: "doc.on.doc")
}
}
if let back = card.fields["back"], !back.isEmpty {
Button {
PlatformClipboard.copy(back)
} label: {
Label("Rückseite kopieren", systemImage: "doc.on.doc.fill")
}
}
Divider()
Button(role: .destructive) {
Task { await deleteCard(card) }
} label: {
Label("Löschen", systemImage: "trash")
}
}
}
}
}
@ -371,6 +393,18 @@ struct DeckDetailView: View {
}
}
private func deleteCard(_ card: Card) async {
cards.removeAll { $0.id == card.id }
let api = WordeckAPI(auth: auth)
do {
try await api.deleteCard(id: card.id)
await loadCards()
} catch {
Log.api.warning("deleteCard failed: \(String(describing: error), privacy: .public)")
await loadCards()
}
}
private func delete() async {
deleteError = nil
let api = WordeckAPI(auth: auth)

View file

@ -107,6 +107,28 @@ struct DeckListView: View {
decks.filter(\.isFromMarketplace)
}
/// Dupliziert ein Deck serverseitig und lädt die Liste neu.
private func duplicateDeck(_ id: String) async {
let api = WordeckAPI(auth: auth)
do {
_ = try await api.duplicateDeck(id: id)
await store?.refresh()
} catch {
Log.api.warning("duplicateDeck failed: \(String(describing: error), privacy: .public)")
}
}
/// Löscht ein Deck serverseitig und lädt die Liste neu.
private func deleteDeck(_ id: String) async {
let api = WordeckAPI(auth: auth)
do {
try await api.deleteDeck(id: id)
await store?.refresh()
} catch {
Log.api.warning("deleteDeck failed: \(String(describing: error), privacy: .public)")
}
}
@ViewBuilder
private func deckSection(title: String, icon: String, decks: [CachedDeck]) -> some View {
if !decks.isEmpty {
@ -132,6 +154,24 @@ struct DeckListView: View {
onEdit: { path.append(DeckRoute.detail(deckId: deck.id)) }
)
.frame(width: 240)
.contextMenu {
Button {
path.append(DeckRoute.detail(deckId: deck.id))
} label: {
Label("Bearbeiten", systemImage: "pencil")
}
Button {
Task { await duplicateDeck(deck.id) }
} label: {
Label("Duplizieren", systemImage: "plus.square.on.square")
}
Divider()
Button(role: .destructive) {
Task { await deleteDeck(deck.id) }
} label: {
Label("Löschen", systemImage: "trash")
}
}
}
}
.padding(.horizontal, 20)

View file

@ -83,6 +83,17 @@ struct BrowseView: View {
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16))
.contextMenu {
let url = "https://wordeck.com/marketplace/decks/\(entry.slug)"
Button {
PlatformClipboard.copy(url)
} label: {
Label("Link kopieren", systemImage: "link")
}
ShareLink(item: URL(string: url) ?? URL(string: "https://wordeck.com")!) {
Label("Teilen …", systemImage: "square.and.arrow.up")
}
}
}
}
.listStyle(.plain)