smlflg's picture
Initial public upload from Projekte/PromtGarageMac
4977db2 verified
Raw History Blame Contribute Delete
13.4 kB
import PromptGarageCore
import SwiftUI
struct LibraryView: View {
@Bindable var model: AppModel
private var title: String {
switch model.destination {
case .all: "Alle Prompts"
case .favorites: "Favoriten"
case .mostUsed: "Meistgenutzt"
case .byDay: "Nach Tagen"
case .byTopic: "Nach Topics"
default: "Bibliothek"
}
}
var body: some View {
VStack(spacing: 0) {
if model.destination == .byDay || model.destination == .byTopic {
groupingBar
Divider()
}
filterBar
Divider()
if model.prompts.isEmpty && !model.isLoadingLibrary {
LibraryEmptyState(
hasActiveQuery: !model.searchText.isEmpty
|| model.selectedTag != nil
|| model.selectedSourceFamily != nil
|| model.destination == .byDay
|| model.destination == .byTopic,
canCreate: model.canMutatePrompts,
clearFilters: model.clearLibraryFilters,
createPrompt: model.presentCreatePrompt
)
} else {
List(selection: $model.selectedPromptID) {
ForEach(model.prompts) { prompt in
PromptRow(prompt: prompt)
.tag(prompt.id)
.contextMenu {
Button("Nur kopieren") {
Task { await model.copy(prompt, countAsUse: false) }
}
Button(prompt.isStarred ? "Aus Favoriten entfernen" : "Zu Favoriten") {
Task { await model.toggleStar(prompt) }
}
.disabled(!model.canMutatePrompts)
Divider()
Button("Bearbeiten …") {
model.presentEditPrompt(prompt)
}
.disabled(!model.canMutatePrompts)
}
}
if model.hasMoreLibraryResults {
HStack {
Text(
"\(model.prompts.count.formatted()) von "
+ "\(model.totalResults.formatted()) geladen"
)
.font(.caption)
.foregroundStyle(.secondary)
Spacer(minLength: 12)
Button {
Task { await model.loadMoreLibraryResults() }
} label: {
if model.isLoadingMoreLibrary {
HStack(spacing: 8) {
ProgressView().controlSize(.small)
Text("Weitere Prompts laden …")
}
} else {
Label(
"Weitere \(model.nextLibraryPageCount.formatted()) laden",
systemImage: "arrow.down.circle"
)
}
}
.buttonStyle(.bordered)
.disabled(!model.canLoadMoreLibraryResults)
}
.padding(.vertical, 10)
.listRowSeparator(.hidden)
} else if model.totalResults > 0 {
Text("Alle \(model.totalResults.formatted()) Prompts geladen")
.font(.caption)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical, 8)
.listRowSeparator(.hidden)
}
}
.listStyle(.inset)
.overlay {
if model.isLoadingLibrary {
ProgressView("Prompts laden …")
.padding()
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 10))
}
}
}
}
.navigationTitle(title)
.searchable(text: $model.searchText, placement: .toolbar, prompt: "Prompts durchsuchen")
.toolbar {
ToolbarItemGroup {
Button {
model.destination = .importCenter
} label: {
Label("Importieren", systemImage: "square.and.arrow.down")
}
Button {
model.presentCreatePrompt()
} label: {
Label("Neuer Prompt", systemImage: "plus")
}
.disabled(!model.canMutatePrompts)
}
}
.task(id: model.libraryQueryKey) {
try? await Task.sleep(for: .milliseconds(180))
guard !Task.isCancelled else { return }
await model.refreshLibrary()
}
}
private var filterBar: some View {
HStack(spacing: 10) {
Text("\(model.totalResults.formatted())")
.font(.headline.monospacedDigit())
Text(model.totalResults == 1 ? "Prompt" : "Prompts")
.font(.subheadline)
.foregroundStyle(.secondary)
Spacer(minLength: 12)
if model.destination != .byTopic {
Menu {
Button("Alle Tags") { model.selectedTag = nil }
Divider()
ForEach(model.availableTags) { tag in
Button {
model.selectedTag = tag.name
} label: {
HStack {
Text(tag.name)
Spacer()
Text(tag.count.formatted())
}
}
}
} label: {
Label(model.selectedTag ?? "Tag", systemImage: "tag")
}
.menuStyle(.borderlessButton)
.fixedSize()
}
Menu {
Button("Alle Quellen") { model.selectedSourceFamily = nil }
Divider()
ForEach(model.availableSourceFamilies) { source in
Button {
model.selectedSourceFamily = source.name
} label: {
HStack {
Text(source.name.capitalized)
Spacer()
Text(source.count.formatted())
}
}
}
} label: {
Label(
model.selectedSourceFamily?.capitalized ?? "Quelle",
systemImage: "shippingbox"
)
}
.menuStyle(.borderlessButton)
.fixedSize()
Picker("Sortierung", selection: $model.sort) {
Text("Zuletzt geändert").tag(PromptQuery.Sort.updatedDescending)
Text("Titel A–Z").tag(PromptQuery.Sort.titleAscending)
Text("Meistgenutzt").tag(PromptQuery.Sort.mostUsed)
}
.labelsHidden()
.frame(width: 155)
if model.selectedTag != nil || model.selectedSourceFamily != nil || !model.searchText.isEmpty {
Button("Filter löschen", action: model.clearLibraryFilters)
.buttonStyle(.borderless)
.font(.caption)
}
}
.padding(.horizontal, 14)
.frame(minHeight: 49)
.background(.bar)
}
@ViewBuilder
private var groupingBar: some View {
if model.destination == .byDay {
VStack(alignment: .leading, spacing: 7) {
HStack {
Label("Prompt-Datum", systemImage: "calendar")
.font(.subheadline.weight(.semibold))
Spacer()
Picker("Tag", selection: $model.selectedDay) {
ForEach(model.dayGroups) { group in
Text("\(formattedDay(group.name)) · \(group.count.formatted())")
.tag(Optional(group.name))
}
}
.labelsHidden()
.frame(maxWidth: 220)
}
if model.legacyUndatedCount > 0 {
Label(
"\(model.legacyUndatedCount.formatted()) Legacy-Prompts: Originaldatum unbekannt. Sie werden nicht umdatiert.",
systemImage: "clock.badge.questionmark"
)
.font(.caption)
.foregroundStyle(.secondary)
}
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(AppUI.accent.opacity(0.045))
} else {
HStack(spacing: 10) {
Label("Bestehendes Tag", systemImage: "tag")
.font(.subheadline.weight(.semibold))
Picker("Topic", selection: $model.selectedTopic) {
ForEach(model.topicGroups) { group in
Text("\(group.displayName) · \(group.count.formatted())")
.tag(Optional(group))
}
}
.labelsHidden()
.frame(maxWidth: 220)
Spacer()
Text("Read-only · kein AI-Clustering")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.horizontal, 14)
.frame(minHeight: 49)
.background(AppUI.accent.opacity(0.045))
}
}
private func formattedDay(_ value: String) -> String {
let parser = DateFormatter()
parser.calendar = Calendar(identifier: .gregorian)
parser.locale = Locale(identifier: "en_US_POSIX")
parser.dateFormat = "yyyy-MM-dd"
guard let date = parser.date(from: value) else { return value }
return date.formatted(date: .abbreviated, time: .omitted)
}
}
private struct PromptRow: View {
let prompt: PromptRecord
var body: some View {
VStack(alignment: .leading, spacing: 7) {
HStack(alignment: .firstTextBaseline, spacing: 7) {
if prompt.isStarred {
Image(systemName: "star.fill")
.font(.caption)
.foregroundStyle(AppUI.accent)
}
Text(prompt.title)
.font(.body.weight(.semibold))
.lineLimit(1)
Spacer(minLength: 8)
if prompt.useCount > 0 {
Text("\(prompt.useCount)×")
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
}
}
Text(prompt.content.replacingOccurrences(of: "\n", with: " "))
.font(.callout)
.foregroundStyle(.secondary)
.lineLimit(2)
HStack(spacing: 6) {
StatusChip(title: prompt.sourceFamily.capitalized, systemImage: "shippingbox")
ForEach(prompt.tagList.prefix(2), id: \.self) { tag in
StatusChip(title: tag, systemImage: "tag")
}
if prompt.tagList.count > 2 {
Text("+\(prompt.tagList.count - 2)")
.font(.caption2)
.foregroundStyle(.tertiary)
}
}
}
.padding(.vertical, 5)
.accessibilityElement(children: .combine)
}
}
private struct LibraryEmptyState: View {
let hasActiveQuery: Bool
let canCreate: Bool
let clearFilters: () -> Void
let createPrompt: () -> Void
var body: some View {
ContentUnavailableView {
Label(
hasActiveQuery ? "Keine Treffer" : "Die Garage ist leer",
systemImage: hasActiveQuery ? "magnifyingglass" : "archivebox"
)
} description: {
Text(
hasActiveQuery
? "Passe Suchbegriff oder Filter an. Deine lokale Sammlung wurde nicht verändert."
: "Erstelle deinen ersten Prompt oder importiere eine bestehende Sammlung."
)
} actions: {
if hasActiveQuery {
Button("Filter löschen", action: clearFilters)
} else if canCreate {
Button("Prompt erstellen", action: createPrompt)
.buttonStyle(.borderedProminent)
}
}
}
}