Schnellnotiz und Editorfenster.

This commit is contained in:
2026-03-25 09:36:36 +01:00
parent bcb6a93dd5
commit 0d8a998ddf
@@ -0,0 +1,262 @@
import SwiftUI
import SwiftData
struct QuickNoteView: View {
@Environment(\.modelContext) private var modelContext
@Environment(ConnectivityMonitor.self) private var connectivity
// Form fields
@State private var title = ""
@State private var content = ""
@State private var tagsRaw = ""
// Location selection
@State private var shelves: [ShelfDTO] = []
@State private var books: [BookDTO] = []
@State private var selectedShelf: ShelfDTO? = nil
@State private var selectedBook: BookDTO? = nil
@State private var isLoadingShelves = false
@State private var isLoadingBooks = false
// Save state
@State private var isSaving = false
@State private var savedMessage: String? = nil
@State private var error: String? = nil
// Pending notes
@Query(sort: \PendingNote.createdAt) private var pendingNotes: [PendingNote]
@State private var isUploadingPending = false
var body: some View {
NavigationStack {
Form {
// Note content
Section(L("quicknote.field.title")) {
TextField(L("quicknote.field.title"), text: $title)
}
Section(L("quicknote.field.content")) {
TextEditor(text: $content)
.frame(minHeight: 120)
.font(.system(.body, design: .monospaced))
}
Section(L("quicknote.field.tags")) {
TextField(L("quicknote.field.tags"), text: $tagsRaw)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
}
// Location: shelf book
Section(L("quicknote.section.location")) {
if isLoadingShelves {
HStack {
ProgressView().controlSize(.small)
Text(L("quicknote.shelf.loading"))
.foregroundStyle(.secondary)
.padding(.leading, 8)
}
} else {
Picker(L("create.shelf.title"), selection: $selectedShelf) {
Text(L("quicknote.shelf.none")).tag(ShelfDTO?.none)
ForEach(shelves) { shelf in
Text(shelf.name).tag(ShelfDTO?.some(shelf))
}
}
.onChange(of: selectedShelf) { _, shelf in
selectedBook = nil
if let shelf {
Task { await loadBooks(for: shelf) }
} else {
Task { await loadAllBooks() }
}
}
}
if isLoadingBooks {
HStack {
ProgressView().controlSize(.small)
Text(L("quicknote.book.loading"))
.foregroundStyle(.secondary)
.padding(.leading, 8)
}
} else {
Picker(L("create.book.title"), selection: $selectedBook) {
Text(L("quicknote.book.none")).tag(BookDTO?.none)
ForEach(books) { book in
Text(book.name).tag(BookDTO?.some(book))
}
}
}
}
// Feedback
if let msg = savedMessage {
Section {
HStack {
Image(systemName: "checkmark.circle.fill").foregroundStyle(.green)
Text(msg).foregroundStyle(.secondary).font(.footnote)
}
}
}
if let err = error {
Section {
HStack {
Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.red)
Text(err).foregroundStyle(.red).font(.footnote)
}
}
}
// Pending notes section
if !pendingNotes.isEmpty {
Section {
ForEach(pendingNotes) { note in
VStack(alignment: .leading, spacing: 2) {
Text(note.title).font(.body)
Text(note.bookName)
.font(.caption)
.foregroundStyle(.secondary)
}
}
.onDelete(perform: deletePending)
Button {
Task { await uploadPending() }
} label: {
if isUploadingPending {
HStack {
ProgressView().controlSize(.small)
Text(L("quicknote.pending.uploading"))
.foregroundStyle(.secondary)
.padding(.leading, 6)
}
} else {
Label(L("quicknote.pending.upload"), systemImage: "arrow.up.circle")
}
}
.disabled(isUploadingPending || !connectivity.isConnected)
} header: {
Text(L("quicknote.pending.title"))
}
}
}
.navigationTitle(L("quicknote.title"))
.toolbar {
ToolbarItem(placement: .confirmationAction) {
if isSaving {
ProgressView().controlSize(.small)
} else {
Button(L("quicknote.save")) {
Task { await save() }
}
.disabled(title.isEmpty || selectedBook == nil)
}
}
}
.task {
await loadShelves()
}
}
}
// MARK: - Load shelves / books
private func loadShelves() async {
isLoadingShelves = true
shelves = (try? await BookStackAPI.shared.fetchShelves()) ?? []
isLoadingShelves = false
await loadAllBooks()
}
private func loadAllBooks() async {
isLoadingBooks = true
books = (try? await BookStackAPI.shared.fetchBooks()) ?? []
isLoadingBooks = false
}
private func loadBooks(for shelf: ShelfDTO) async {
isLoadingBooks = true
books = (try? await BookStackAPI.shared.fetchShelf(id: shelf.id))?.books ?? []
isLoadingBooks = false
}
// MARK: - Save
private func save() async {
guard let book = selectedBook else {
error = L("quicknote.error.nobook")
return
}
error = nil
savedMessage = nil
isSaving = true
let tagDTOs = parsedTags()
if connectivity.isConnected {
do {
let page = try await BookStackAPI.shared.createPage(
bookId: book.id,
name: title,
markdown: content,
tags: tagDTOs
)
AppLog(.info, "Quick note '\(title)' created as page \(page.id)", category: "QuickNote")
savedMessage = L("quicknote.saved.online")
resetForm()
} catch {
self.error = error.localizedDescription
}
} else {
let tagsString = tagDTOs.map { "\($0.name):\($0.value)" }.joined(separator: ",")
let pending = PendingNote(
title: title,
markdown: content,
tags: tagsString,
bookId: book.id,
bookName: book.name
)
modelContext.insert(pending)
try? modelContext.save()
savedMessage = L("quicknote.saved.offline")
resetForm()
}
isSaving = false
}
// MARK: - Pending
private func uploadPending() async {
isUploadingPending = true
await SyncService.shared.flushPendingNotes(context: modelContext)
isUploadingPending = false
}
private func deletePending(at offsets: IndexSet) {
for i in offsets {
modelContext.delete(pendingNotes[i])
}
try? modelContext.save()
}
// MARK: - Helpers
private func parsedTags() -> [TagDTO] {
tagsRaw.split(separator: ",").compactMap { token -> TagDTO? in
let parts = token.trimmingCharacters(in: .whitespaces)
.split(separator: ":", maxSplits: 1)
.map(String.init)
guard parts.count == 2 else { return nil }
return TagDTO(name: parts[0].trimmingCharacters(in: .whitespaces),
value: parts[1].trimmingCharacters(in: .whitespaces),
order: 0)
}
}
private func resetForm() {
title = ""
content = ""
tagsRaw = ""
}
}