Improve content saving, label editing

This commit is contained in:
Christoph Hagen 2025-05-02 22:11:43 +02:00
parent fea06a93b7
commit 1f4f32c9af
15 changed files with 274 additions and 150 deletions

View File

@ -204,6 +204,9 @@
E2EC1FB42DC0FA8700C41784 /* Insert+Route.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2EC1FB32DC0FA6D00C41784 /* Insert+Route.swift */; };
E2F3B3832DC496CB00CFA712 /* GalleryBlock.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2F3B3822DC496C800CFA712 /* GalleryBlock.swift */; };
E2F3B3852DC49B7A00CFA712 /* Insert+Gallery.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2F3B3842DC49B4400CFA712 /* Insert+Gallery.swift */; };
E2F3B3982DC54F9400CFA712 /* ChangeObservingItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2F3B3972DC54F8600CFA712 /* ChangeObservingItem.swift */; };
E2F3B39C2DC5542E00CFA712 /* LabelEditingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2F3B39B2DC5542E00CFA712 /* LabelEditingView.swift */; };
E2F3B39E2DC55B1C00CFA712 /* LabelCreationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2F3B39D2DC55B1C00CFA712 /* LabelCreationView.swift */; };
E2FD1D0D2D2DBBA600B48627 /* LinkPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FD1D0C2D2DBBA100B48627 /* LinkPreview.swift */; };
E2FD1D192D2DC4F500B48627 /* LoadingContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FD1D182D2DC4F500B48627 /* LoadingContext.swift */; };
E2FD1D1B2D2DC63800B48627 /* LinkPreviewDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FD1D1A2D2DC62C00B48627 /* LinkPreviewDetailView.swift */; };
@ -484,6 +487,9 @@
E2EC1FB32DC0FA6D00C41784 /* Insert+Route.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Insert+Route.swift"; sourceTree = "<group>"; };
E2F3B3822DC496C800CFA712 /* GalleryBlock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryBlock.swift; sourceTree = "<group>"; };
E2F3B3842DC49B4400CFA712 /* Insert+Gallery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Insert+Gallery.swift"; sourceTree = "<group>"; };
E2F3B3972DC54F8600CFA712 /* ChangeObservingItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChangeObservingItem.swift; sourceTree = "<group>"; };
E2F3B39B2DC5542E00CFA712 /* LabelEditingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LabelEditingView.swift; sourceTree = "<group>"; };
E2F3B39D2DC55B1C00CFA712 /* LabelCreationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LabelCreationView.swift; sourceTree = "<group>"; };
E2FD1D0C2D2DBBA100B48627 /* LinkPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkPreview.swift; sourceTree = "<group>"; };
E2FD1D182D2DC4F500B48627 /* LoadingContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingContext.swift; sourceTree = "<group>"; };
E2FD1D1A2D2DC62C00B48627 /* LinkPreviewDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkPreviewDetailView.swift; sourceTree = "<group>"; };
@ -676,6 +682,7 @@
E229901A2D0E3F09009F8D77 /* Item */ = {
isa = PBXGroup;
children = (
E2F3B3972DC54F8600CFA712 /* ChangeObservingItem.swift */,
E2FD1D1E2D2E9CBE00B48627 /* ItemId.swift */,
E2FD1D1C2D2DE31600B48627 /* ItemType.swift */,
E2FE0F162D2698D5002963B7 /* LocalizedPageId.swift */,
@ -995,6 +1002,7 @@
E2B85F4B2C4B8B7F0047CD0C /* Posts */ = {
isa = PBXGroup;
children = (
E2F3B39B2DC5542E00CFA712 /* LabelEditingView.swift */,
E2FD1D632D47EF4200B48627 /* DetailListItem.swift */,
E2FD1D452D46427B00B48627 /* PageIconView.swift */,
E2FD1D3E2D46404900B48627 /* PostLabelsView.swift */,
@ -1101,6 +1109,7 @@
E2FD1D352D3BBCAF00B48627 /* Commands */ = {
isa = PBXGroup;
children = (
E2F3B39D2DC55B1C00CFA712 /* LabelCreationView.swift */,
E2BF1BC72D6FC87C003089F1 /* Insert+Link.swift */,
E2FD1D592D477AB200B48627 /* InsertableItemsView.swift */,
E2FD1D572D477A9400B48627 /* InsertableCommand.swift */,
@ -1412,10 +1421,12 @@
E229901E2D0E4364009F8D77 /* LocalizedItem.swift in Sources */,
E29D31262D0370A80051B7F4 /* VideoCommand+Option.swift in Sources */,
E2FE0EF82D1D8110002963B7 /* IconCommand.swift in Sources */,
E2F3B39E2DC55B1C00CFA712 /* LabelCreationView.swift in Sources */,
E21850272CF3B42D0090B18B /* PostDetailView.swift in Sources */,
E22990242D0EDBD0009F8D77 /* HeaderElement.swift in Sources */,
E2BF1BCA2D70EDF8003089F1 /* TagPropertyView.swift in Sources */,
E29D31BC2D0DB5120051B7F4 /* CommandProcessor.swift in Sources */,
E2F3B39C2DC5542E00CFA712 /* LabelEditingView.swift in Sources */,
E2FE0F662D2C3B3A002963B7 /* LabelsBlock.swift in Sources */,
E20BCCAF2D53F4A500B8DBEB /* GenerationStringIssuesView.swift in Sources */,
E29D312C2D039DB80051B7F4 /* PageDetailView.swift in Sources */,
@ -1508,6 +1519,7 @@
E2FD1D1F2D2E9CC200B48627 /* ItemId.swift in Sources */,
E2FE0EFA2D25AFBA002963B7 /* PageHeader.swift in Sources */,
E25DA5252CFF73AB00AEF16D /* NSSize+Scaling.swift in Sources */,
E2F3B3982DC54F9400CFA712 /* ChangeObservingItem.swift in Sources */,
E21850372CFCA55F0090B18B /* LocalizedPostSettings.swift in Sources */,
E2FD1D642D47EF4200B48627 /* DetailListItem.swift in Sources */,
E2FE0F0B2D2689FF002963B7 /* FeedGeneratorSource.swift in Sources */,

View File

@ -184,7 +184,9 @@ final class Content: ObservableObject {
private(set) var lastModification: Date = .now
func update(saveState: SaveState) {
self.saveState = saveState
DispatchQueue.main.async {
self.saveState = saveState
}
}
func setModificationTimestamp() {

View File

@ -1,17 +1,10 @@
import Foundation
final class ContentLabel: ObservableObject {
struct ContentLabel {
@Published
var icon: PageIcon
@Published
var value: String
init(icon: PageIcon, value: String) {
self.icon = icon
self.value = value
}
}
extension ContentLabel: Equatable {
@ -34,7 +27,7 @@ extension ContentLabel {
.init(icon: icon.rawValue, value: value)
}
convenience init?(context: LoadingContext, data: Data) {
init?(context: LoadingContext, data: Data) {
guard let icon = PageIcon(rawValue: data.icon) else {
context.error("Unknown label icon '\(data.icon)'")
return nil

View File

@ -0,0 +1,27 @@
import Foundation
import Combine
class ChangeObservingItem: ObservableContentItem {
unowned let content: Content
/// A dummy property to force views to update when properties change
@Published
private var changeToggle = false
var cancellables = Set<AnyCancellable>()
init(content: Content) {
self.content = content
observeChanges()
}
// MARK: Change observation
func didChange() {
DispatchQueue.main.async {
self.changeToggle.toggle()
}
}
}

View File

@ -1,9 +1,7 @@
import Foundation
import Combine
class Item: ObservableContentItem, Identifiable {
unowned let content: Content
class Item: ChangeObservingItem, Identifiable {
/// A dummy property to force views to update when properties change
@Published
@ -12,23 +10,13 @@ class Item: ObservableContentItem, Identifiable {
@Published
var id: String
var cancellables = Set<AnyCancellable>()
init(content: Content, id: String) {
self.content = content
self.id = id
super.init(content: content)
observeChanges()
}
// MARK: Change observation
func didChange() {
DispatchQueue.main.async {
self.changeToggle.toggle()
}
}
// MARK: Paths
func makeCleanAbsolutePath(_ path: String) -> String {

View File

@ -6,9 +6,7 @@ import SwiftUI
including the title, url path and required resources
*/
final class LocalizedPage: ObservableObject {
unowned let content: Content
final class LocalizedPage: ChangeObservingItem {
/**
The string to use when creating the url for the page.
@ -50,13 +48,13 @@ final class LocalizedPage: ObservableObject {
originalUrl: String? = nil,
linkPreview: LinkPreview = .init(),
hideTitle: Bool = false) {
self.content = content
self.urlString = urlString
self.title = title
self.lastModified = lastModified
self.originalUrl = originalUrl
self.linkPreview = linkPreview
self.hideTitle = hideTitle
super.init(content: content)
}
func isValid(urlComponent: String) -> Bool {

View File

@ -1,9 +1,7 @@
import Foundation
import SwiftUI
final class LocalizedPost: ObservableObject {
unowned let content: Content
final class LocalizedPost: ChangeObservingItem {
@Published
var title: String?
@ -36,7 +34,6 @@ final class LocalizedPost: ObservableObject {
labels: [ContentLabel] = [],
pageLinkText: String? = nil,
linkPreview: LinkPreview = .init()) {
self.content = content
self.title = title
self.text = text
self.lastModified = lastModified
@ -44,6 +41,7 @@ final class LocalizedPost: ObservableObject {
self.labels = labels
self.pageLinkText = pageLinkText
self.linkPreview = linkPreview
super.init(content: content)
}
func contains(_ string: String) -> Bool {

View File

@ -1,8 +1,6 @@
import Foundation
final class LocalizedTag: ObservableObject {
unowned let content: Content
final class LocalizedTag: ChangeObservingItem {
@Published
var urlComponent: String
@ -22,11 +20,11 @@ final class LocalizedTag: ObservableObject {
name: String,
linkPreview: LinkPreview = .init(),
originalUrl: String? = nil) {
self.content = content
self.urlComponent = urlComponent
self.name = name
self.linkPreview = linkPreview
self.originalUrl = originalUrl
super.init(content: content)
}
func isValid(urlComponent: String) -> Bool {

View File

@ -37,7 +37,7 @@ enum RouteStatisticType: String, CaseIterable {
case .speed: "km/h"
case .pace: "min/km"
case .heartRate: "bpm"
case .energy: "kcal"
case .energy: "kcal/min"
}
}

View File

@ -12,8 +12,14 @@ struct UploadSheet: View {
@Environment(\.dismiss)
private var dismiss
private let lineLimit = 4
@State
private var output: [String] = ["Ready to upload"]
private var output: [String]
init(output: [String] = ["Ready to upload", "", "", ""]) {
self.output = output
}
private var uploadSymbol: SFSymbol {
if upload.isTransmittingToRemote {
@ -34,7 +40,7 @@ struct UploadSheet: View {
}
var body: some View {
VStack {
VStack(alignment: .leading) {
HStack {
Button("Upload", action: startUpload)
.disabled(upload.isTransmittingToRemote)
@ -42,12 +48,26 @@ struct UploadSheet: View {
Spacer()
Button("Close", action: { dismiss() })
}
ScrollView {
Text(output.joined(separator: "\n"))
.font(.body.monospaced())
.foregroundStyle(.primary)
.frame(maxWidth: .infinity, alignment: .leading)
VStack(alignment: .leading) {
Text(output[0])
Text(output[1])
Text(output[2])
Text(output[3])
}
.font(.body.monospaced())
.lineLimit(1)
// TextField("", text: .constant(output.joined(separator: "\n")))
// .font(.body.monospaced())
// .textFieldStyle(.plain)
// .lineLimit(lineLimit)
// .disabled(true)
// .frame(minHeight: 150)
// ScrollView {
// Text(output.joined(separator: "\n"))
// .font(.body.monospaced())
// .foregroundStyle(.primary)
// .frame(maxWidth: .infinity, alignment: .leading)
// }
}
.padding()
.frame(minWidth: 500, idealWidth: 600)
@ -55,18 +75,28 @@ struct UploadSheet: View {
private func startUpload() {
guard let folder = content.storage.outputScope?.url.path() else {
output = ["No output folder to start upload"]
output = ["No output folder to start upload", "", "", ""]
return
}
output = ["Starting upload..."]
output = ["Starting upload...", "", "", ""]
upload.transmitToRemote(
settings: content.settings.general,
outputFolder: folder) { newContent in
DispatchQueue.main.async {
let newLines = newContent.components(separatedBy: "\n").suffix(4)
self.output = (self.output + newLines).suffix(4)
let newLines = newContent.components(separatedBy: "\n").suffix(lineLimit)
if newLines.count >= lineLimit {
self.output = newLines.suffix(lineLimit)
} else {
self.output = (self.output + newLines).suffix(lineLimit)
}
}
}
}
}
#Preview {
UploadSheet(output: [
"Some very long text that should cause the view to scroll", "More", "Some", "Yes"
])
}

View File

@ -160,7 +160,7 @@ private struct FileButtonView: View {
var body: some View {
HStack {
LabelEditingView(label: content.label)
LabelEditingView(label: $content.label)
Button("\(content.file?.id ?? "Select file")", action: { showFileSelectionSheet = true })
OptionalTextField("", text: $content.downloadedFileName, prompt: "Downloaded file name")
.textFieldStyle(.roundedBorder)
@ -178,7 +178,7 @@ private struct UrlButtonView: View {
var body: some View {
HStack {
LabelEditingView(label: content.label)
LabelEditingView(label: $content.label)
TextField("", text: $content.url, prompt: Text("URL"))
.textFieldStyle(.roundedBorder)
}
@ -192,7 +192,7 @@ private struct EventButtonView: View {
var body: some View {
HStack {
LabelEditingView(label: content.label)
LabelEditingView(label: $content.label)
TextField("", text: $content.event, prompt: Text("Javascript"))
.textFieldStyle(.roundedBorder)
}

View File

@ -35,47 +35,15 @@ struct InsertableLabels: View, InsertableCommandView {
}
}
@Environment(\.colorScheme)
private var colorScheme
@ObservedObject
private var model: Model
init(model: Model) {
self.model = model
}
var body: some View {
VStack(spacing: 2) {
ForEach(model.labels, id: \.icon) { label in
HStack {
Button(action: { remove(label) }) {
Image(systemSymbol: .minusCircleFill)
.foregroundStyle(.red)
}
.buttonStyle(.plain)
LabelEditingView(label: label)
}
.padding(.vertical, 2)
.padding(.horizontal, 8)
.background(colorScheme == .light ? Color.white : Color.black)
.cornerRadius(8)
}
Button("Add", action: addLabel)
.padding(.vertical, 2)
}
LabelCreationView(labels: $model.labels)
}
private func addLabel() {
model.labels.append(.init(icon: .clockFill, value: "Value"))
}
private func remove(_ label: ContentLabel) {
guard let index = model.labels.firstIndex(of: label) else {
return
}
model.labels.remove(at: index)
}
}

View File

@ -0,0 +1,54 @@
import SwiftUI
struct LabelCreationView: View {
@Environment(\.colorScheme)
private var colorScheme
@Binding
var labels: [ContentLabel]
var body: some View {
List {
ForEach($labels) { label in
HStack {
Button(action: { remove(label.wrappedValue) }) {
Image(systemSymbol: .minusCircleFill)
.foregroundStyle(.red)
}
.buttonStyle(.plain)
LabelEditingView(label: label)
}
.padding(.vertical, 2)
.padding(.horizontal, 8)
.background(colorScheme == .light ? Color.white : Color.black)
.cornerRadius(8)
}
.onMove(perform: moveLabel)
Button("Add new label", action: addLabel)
.padding(.vertical, 2)
}
.frame(minHeight: 250)
}
private func addLabel() {
var label = ContentLabel(icon: .statisticsTime, value: "Value")
var number = 0
while labels.contains(label) {
number += 1
label.value = "Value \(number)"
}
labels.append(label)
}
private func remove(_ label: ContentLabel) {
guard let index = labels.firstIndex(of: label) else {
return
}
labels.remove(at: index)
}
private func moveLabel(from source: IndexSet, to destination: Int) {
labels.move(fromOffsets: source, toOffset: destination)
}
}

View File

@ -0,0 +1,65 @@
import SwiftUI
struct LabelEditingView: View {
@Binding
var label: ContentLabel
@State
private var showIconPicker: Bool = false
let scale: CGFloat
init(label: Binding<ContentLabel>, scale: CGFloat = 1.0) {
self._label = label
self.scale = scale
}
var body: some View {
HStack {
Button(action: { showIconPicker = true }) {
PageIconView(icon: label.icon)
.frame(maxWidth: 16, maxHeight: 16)
.scaleEffect(scale)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
TextField("", text: $label.value)
.textFieldStyle(.plain)
}
.sheet(isPresented: $showIconPicker) {
LabelIconSelectionView(selected: $label.icon)
}
}
}
private struct LabelIconSelectionView: View {
@Environment(\.dismiss)
var dismiss
@Binding
var selected: PageIcon
var body: some View {
VStack {
List(PageIcon.allCases, id: \.rawValue) { icon in
HStack {
Image(systemSymbol: selected == icon ? .checkmarkCircleFill : .circle)
PageIconView(icon: icon)
.frame(maxWidth: 20, maxHeight: 20)
Text(icon.name)
Spacer()
}
.contentShape(Rectangle())
.onTapGesture {
selected = icon
dismiss()
}
}.frame(minHeight: 300)
Button("Done") {
dismiss()
}
}.padding()
}
}

View File

@ -1,61 +1,5 @@
import SwiftUI
struct LabelEditingView: View {
@ObservedObject
var label: ContentLabel
@State
private var showIconPicker: Bool = false
var body: some View {
HStack {
Button(action: { showIconPicker = true }) {
PageIconView(icon: label.icon)
.frame(maxWidth: 20, maxHeight: 20)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
TextField("", text: $label.value)
.textFieldStyle(.plain)
}
.sheet(isPresented: $showIconPicker) {
LabelIconSelectionView(selected: $label.icon)
}
}
}
private struct LabelIconSelectionView: View {
@Environment(\.dismiss)
var dismiss
@Binding
var selected: PageIcon
var body: some View {
VStack {
List(PageIcon.allCases, id: \.rawValue) { icon in
HStack {
Image(systemSymbol: selected == icon ? .checkmarkCircleFill : .circle)
PageIconView(icon: icon)
.frame(maxWidth: 20, maxHeight: 20)
Text(icon.name)
Spacer()
}
.contentShape(Rectangle())
.onTapGesture {
selected = icon
dismiss()
}
}.frame(minHeight: 300)
Button("Done") {
dismiss()
}
}.padding()
}
}
struct PostLabelsView: View {
@ObservedObject
@ -67,26 +11,38 @@ struct PostLabelsView: View {
@Environment(\.colorScheme)
var colorScheme
@State
private var showLabelEditor: Bool = false
var body: some View {
ScrollView(.horizontal) {
HStack(spacing: 5) {
Text("Labels")
.font(.headline)
ForEach(post.labels, id: \.icon) { label in
if post.labels.isEmpty {
Text("Labels")
.font(.headline)
}
ForEach(post.labels) { label in
HStack {
Button(action: { remove(label) }) {
Image(systemSymbol: .minusCircleFill)
.foregroundStyle(.red)
}
.buttonStyle(.plain)
LabelEditingView(label: label)
PageIconView(icon: label.icon)
.frame(maxWidth: 16, maxHeight: 16)
.scaleEffect(25/16)
Text(label.value)
}
.padding(.vertical, 2)
.padding(.horizontal, 8)
.background(colorScheme == .light ? Color.white : Color.black)
.cornerRadius(8)
}
Button("Add", action: addLabel)
Button(action: { showLabelEditor = true }) {
Image(systemSymbol: .squareAndPencilCircleFill)
.resizable()
.aspectRatio(1, contentMode: .fit)
.frame(height: 22)
.foregroundColor(Color.gray)
.background(Circle()
.fill(Color.white)
.padding(1))
}.buttonStyle(.plain)
if !other.labels.isEmpty {
Button("Transfer") {
post.labels = other.labels.map {
@ -95,9 +51,24 @@ struct PostLabelsView: View {
}
}
}
if !post.labels.isEmpty {
Button("Copy") {
var command = "```labels"
for label in post.labels {
command += "\n\(label.icon.rawValue): \(label.value)"
}
command += "\n```"
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
pasteboard.setString(command, forType: .string)
}
}
}
.padding(.vertical, 2)
}
.sheet(isPresented: $showLabelEditor) {
LabelModificationView(labels: $post.labels)
}
}
func addLabel() {
@ -111,3 +82,23 @@ struct PostLabelsView: View {
post.labels.remove(at: index)
}
}
private struct LabelModificationView: View {
@Environment(\.dismiss)
private var dismiss
@Binding
var labels: [ContentLabel]
var body: some View {
VStack {
Text("Labels")
.font(.title)
LabelCreationView(labels: $labels)
Button("Save") {
dismiss()
}
}.padding()
}
}