From a35c2d669e6b0dcef361a737fbcb2a5643ad8330 Mon Sep 17 00:00:00 2001 From: Christoph Hagen Date: Wed, 20 Nov 2024 23:46:54 +0100 Subject: [PATCH] Add images to posts, saving --- CHDataManagement.xcodeproj/project.pbxproj | 34 ++++++++- CHDataManagement/CHDataManagementApp.swift | 8 ++ .../Extensions/String+Extensions.swift | 4 + CHDataManagement/Import/Importer.swift | 75 +++---------------- CHDataManagement/Model/Content.swift | 29 ++++++- CHDataManagement/Model/FileResource.swift | 2 +- CHDataManagement/Model/ImageResource.swift | 7 ++ CHDataManagement/Model/LocalizedPost.swift | 4 - CHDataManagement/Model/Post+Storage.swift | 26 +++++++ CHDataManagement/Model/Tag+Storage.swift | 22 ++++++ .../Preview Content/Post+Mock.swift | 25 ++++--- CHDataManagement/Storage/FileOnDisk.swift | 23 ++++++ CHDataManagement/Storage/FileType.swift | 25 +++++++ CHDataManagement/Storage/PageOnDisk.swift | 11 +++ CHDataManagement/Storage/Storage.swift | 31 ++++++-- CHDataManagement/Views/ColorPalette.swift | 9 ++- .../Views/Generic/VerticalCenter.swift | 33 ++++++++ .../Views/Posts/ImagePickerView.swift | 60 +++++++++++++++ .../Views/Posts/PostImageGalleryView.swift | 68 +++++++++++------ CHDataManagement/Views/Posts/PostView.swift | 49 +++++++----- CHDataManagement/Views/Posts/TagView.swift | 19 +---- 21 files changed, 415 insertions(+), 149 deletions(-) create mode 100644 CHDataManagement/Model/Post+Storage.swift create mode 100644 CHDataManagement/Model/Tag+Storage.swift create mode 100644 CHDataManagement/Storage/FileOnDisk.swift create mode 100644 CHDataManagement/Storage/FileType.swift create mode 100644 CHDataManagement/Storage/PageOnDisk.swift create mode 100644 CHDataManagement/Views/Generic/VerticalCenter.swift create mode 100644 CHDataManagement/Views/Posts/ImagePickerView.swift diff --git a/CHDataManagement.xcodeproj/project.pbxproj b/CHDataManagement.xcodeproj/project.pbxproj index 5ef56f6..b59a0fb 100644 --- a/CHDataManagement.xcodeproj/project.pbxproj +++ b/CHDataManagement.xcodeproj/project.pbxproj @@ -10,6 +10,14 @@ E21850092CEE01C30090B18B /* PagePickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E21850082CEE01BF0090B18B /* PagePickerView.swift */; }; E218500B2CEE02FD0090B18B /* Content+Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = E218500A2CEE02FA0090B18B /* Content+Mock.swift */; }; E218500D2CEE07180090B18B /* ColorPalette.swift in Sources */ = {isa = PBXBuildFile; fileRef = E218500C2CEE07140090B18B /* ColorPalette.swift */; }; + E21850112CEE17070090B18B /* Page+Storage.swift in Sources */ = {isa = PBXBuildFile; fileRef = E21850102CEE17010090B18B /* Page+Storage.swift */; }; + E21850132CEE541D0090B18B /* Post+Storage.swift in Sources */ = {isa = PBXBuildFile; fileRef = E21850122CEE541A0090B18B /* Post+Storage.swift */; }; + E21850152CEE55D40090B18B /* FileOnDisk.swift in Sources */ = {isa = PBXBuildFile; fileRef = E21850142CEE55D40090B18B /* FileOnDisk.swift */; }; + E21850172CEE55FC0090B18B /* FileType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E21850162CEE55FB0090B18B /* FileType.swift */; }; + E21850192CEE561C0090B18B /* PageOnDisk.swift in Sources */ = {isa = PBXBuildFile; fileRef = E21850182CEE561B0090B18B /* PageOnDisk.swift */; }; + E218501B2CEE59EC0090B18B /* Tag+Storage.swift in Sources */ = {isa = PBXBuildFile; fileRef = E218501A2CEE59E80090B18B /* Tag+Storage.swift */; }; + E218501D2CEE6CB60090B18B /* VerticalCenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = E218501C2CEE6CB30090B18B /* VerticalCenter.swift */; }; + E218501F2CEE6DAC0090B18B /* ImagePickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E218501E2CEE6DAC0090B18B /* ImagePickerView.swift */; }; E24252012C50E0A40029FF16 /* HighlightedTextEditor in Frameworks */ = {isa = PBXBuildFile; productRef = E24252002C50E0A40029FF16 /* HighlightedTextEditor */; }; E24252032C5163CF0029FF16 /* Importer.swift in Sources */ = {isa = PBXBuildFile; fileRef = E24252022C5163CF0029FF16 /* Importer.swift */; }; E24252062C51684E0029FF16 /* GenericMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = E24252052C51684E0029FF16 /* GenericMetadata.swift */; }; @@ -73,6 +81,14 @@ E21850082CEE01BF0090B18B /* PagePickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PagePickerView.swift; sourceTree = ""; }; E218500A2CEE02FA0090B18B /* Content+Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Content+Mock.swift"; sourceTree = ""; }; E218500C2CEE07140090B18B /* ColorPalette.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorPalette.swift; sourceTree = ""; }; + E21850102CEE17010090B18B /* Page+Storage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Page+Storage.swift"; sourceTree = ""; }; + E21850122CEE541A0090B18B /* Post+Storage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Post+Storage.swift"; sourceTree = ""; }; + E21850142CEE55D40090B18B /* FileOnDisk.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileOnDisk.swift; sourceTree = ""; }; + E21850162CEE55FB0090B18B /* FileType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileType.swift; sourceTree = ""; }; + E21850182CEE561B0090B18B /* PageOnDisk.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageOnDisk.swift; sourceTree = ""; }; + E218501A2CEE59E80090B18B /* Tag+Storage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Tag+Storage.swift"; sourceTree = ""; }; + E218501C2CEE6CB30090B18B /* VerticalCenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerticalCenter.swift; sourceTree = ""; }; + E218501E2CEE6DAC0090B18B /* ImagePickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagePickerView.swift; sourceTree = ""; }; E24252022C5163CF0029FF16 /* Importer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Importer.swift; sourceTree = ""; }; E24252052C51684E0029FF16 /* GenericMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GenericMetadata.swift; sourceTree = ""; }; E24252072C5168750029FF16 /* GenericMetadata+Localized.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GenericMetadata+Localized.swift"; sourceTree = ""; }; @@ -176,6 +192,7 @@ E2A21C372CB9A4F10060935B /* Generic */ = { isa = PBXGroup; children = ( + E218501C2CEE6CB30090B18B /* VerticalCenter.swift */, E2A37D2C2CED2EEE0000979F /* OptionalTextField.swift */, E2A21C2F2CB490F90060935B /* HorizontalCenter.swift */, E2A21C0F2CB18B390060935B /* FlowHStack.swift */, @@ -204,10 +221,13 @@ E2A37D0F2CE5375E0000979F /* Storage */ = { isa = PBXGroup; children = ( - E2A37D162CE73F170000979F /* TagFile.swift */, + E21850142CEE55D40090B18B /* FileOnDisk.swift */, + E21850162CEE55FB0090B18B /* FileType.swift */, E2A37D102CE537670000979F /* PageFile.swift */, + E21850182CEE561B0090B18B /* PageOnDisk.swift */, E2A37D142CE68BEA0000979F /* PostFile.swift */, E2A37D0D2CE527040000979F /* Storage.swift */, + E2A37D162CE73F170000979F /* TagFile.swift */, ); path = Storage; sourceTree = ""; @@ -231,9 +251,12 @@ E25A0B882CE4021400F33674 /* LocalizedPage.swift */, E2A21C042CB176670060935B /* LocalizedText.swift */, E2A9CB7D2C7BCF2A005C89CC /* Page.swift */, + E21850102CEE17010090B18B /* Page+Storage.swift */, E2B85F3A2C428F0D0047CD0C /* Post.swift */, + E21850122CEE541A0090B18B /* Post+Storage.swift */, E2A37D1C2CEA922A0000979F /* LocalizedPost.swift */, E2581DEC2C75202400F1F079 /* Tag.swift */, + E218501A2CEE59E80090B18B /* Tag+Storage.swift */, E2A37D182CEA36A40000979F /* LocalizedTag.swift */, ); path = Model; @@ -277,6 +300,7 @@ E2B85F4B2C4B8B7F0047CD0C /* Posts */ = { isa = PBXGroup; children = ( + E218501E2CEE6DAC0090B18B /* ImagePickerView.swift */, E21850082CEE01BF0090B18B /* PagePickerView.swift */, E2A21C112CB18D520060935B /* DatePickerView.swift */, E2A21C152CB1A3C60060935B /* PostImageGalleryView.swift */, @@ -432,6 +456,7 @@ E24252062C51684E0029FF16 /* GenericMetadata.swift in Sources */, E2A21C462CBAE2E60060935B /* FeedEntryContent.swift in Sources */, E2A37D1D2CEA922D0000979F /* LocalizedPost.swift in Sources */, + E21850172CEE55FC0090B18B /* FileType.swift in Sources */, E2A37D112CE537800000979F /* PageFile.swift in Sources */, E242520A2C52C9260029FF16 /* ContentLanguage.swift in Sources */, E2B85F452C429ED60047CD0C /* ImageGallery.swift in Sources */, @@ -450,24 +475,31 @@ E2581DED2C75202400F1F079 /* Tag.swift in Sources */, E2A21C4F2CBB29E50060935B /* ImageDetailsView.swift in Sources */, E2A37D152CE68BEC0000979F /* PostFile.swift in Sources */, + E218501B2CEE59EC0090B18B /* Tag+Storage.swift in Sources */, E2A21C032CB16C290060935B /* Environment+Language.swift in Sources */, E2A37D172CE73F1A0000979F /* TagFile.swift in Sources */, E2A37D292CED2C6A0000979F /* TagsListView.swift in Sources */, E24252032C5163CF0029FF16 /* Importer.swift in Sources */, E2A37D252CEBD7A10000979F /* PageListView.swift in Sources */, + E21850152CEE55D40090B18B /* FileOnDisk.swift in Sources */, E2A21C332CB5BCAC0060935B /* PageDetailView.swift in Sources */, E2A21C122CB18D560060935B /* DatePickerView.swift in Sources */, + E21850192CEE561C0090B18B /* PageOnDisk.swift in Sources */, E2A21C4D2CBB16B50060935B /* ImagesView.swift in Sources */, E2A21C202CB28ED20060935B /* MockImage.swift in Sources */, E2A21C2C2CB2BB250060935B /* PostList.swift in Sources */, + E21850112CEE17070090B18B /* Page+Storage.swift in Sources */, E218500D2CEE07180090B18B /* ColorPalette.swift in Sources */, + E21850132CEE541D0090B18B /* Post+Storage.swift in Sources */, E2B85F412C4294790047CD0C /* PageHead.swift in Sources */, + E218501D2CEE6CB60090B18B /* VerticalCenter.swift in Sources */, E2A9CB7E2C7BCF2A005C89CC /* Page.swift in Sources */, E2E06DFB2CA4A65E0019C2AF /* Content.swift in Sources */, E218500B2CEE02FD0090B18B /* Content+Mock.swift in Sources */, E2A21C3B2CB9D9A60060935B /* ImageResource.swift in Sources */, E2A21C302CB490F90060935B /* HorizontalCenter.swift in Sources */, E2DD04742C276F31003BFF1F /* CHDataManagementApp.swift in Sources */, + E218501F2CEE6DAC0090B18B /* ImagePickerView.swift in Sources */, E2A37D1B2CEA45560000979F /* Tag+Mock.swift in Sources */, E2A21C482CBAF88B0060935B /* String+Extensions.swift in Sources */, E2A37D1F2CEA94370000979F /* Optional+Extensions.swift in Sources */, diff --git a/CHDataManagement/CHDataManagementApp.swift b/CHDataManagement/CHDataManagementApp.swift index 079e975..c39c551 100644 --- a/CHDataManagement/CHDataManagementApp.swift +++ b/CHDataManagement/CHDataManagementApp.swift @@ -59,9 +59,17 @@ struct CHDataManagementApp: App { } } .onAppear(perform: importOldContent) + .onReceive(Timer.publish(every: 60.0, on: .main, in: .common).autoconnect()) { _ in + save() + } } } + private func save() { + // Save all changed files + content.saveToDisk() + } + private func importOldContent() { do { try content.loadFromDisk() diff --git a/CHDataManagement/Extensions/String+Extensions.swift b/CHDataManagement/Extensions/String+Extensions.swift index a38c60e..a1a5efe 100644 --- a/CHDataManagement/Extensions/String+Extensions.swift +++ b/CHDataManagement/Extensions/String+Extensions.swift @@ -8,4 +8,8 @@ extension String { .replacingOccurrences(of: "<", with: "<") .replacingOccurrences(of: ">", with: ">") } + + var nonEmpty: String? { + isEmpty ? nil : self + } } diff --git a/CHDataManagement/Import/Importer.swift b/CHDataManagement/Import/Importer.swift index 3b2dd58..54ae1ed 100644 --- a/CHDataManagement/Import/Importer.swift +++ b/CHDataManagement/Import/Importer.swift @@ -1,69 +1,14 @@ import Foundation -enum FileType { - case image - case file - case video - case resource - - - init(fileExtension: String) { - switch fileExtension.lowercased() { - case "jpg", "jpeg", "png", "gif": - self = .image - case "html", "stl", "f3d", "step", "f3z", "zip", "json", "conf", "css", "js", "cpp", "cddx", "svg", "glb", "mp3", "pdf", "swift": - self = .file - case "mp4": - self = .video - case "key", "psd": - self = .resource - default: - print("Unhandled file type: \(fileExtension)") - self = .resource - } - } -} - -struct ImportedPage { - - let page: PageFile - - let deContentUrl: URL - - let enContentUrl: URL -} - - -struct FileResource { - - let type: FileType - - let url: URL - - let name: String - - init(image: String, url: URL) { - self.type = .image - self.url = url - self.name = image - } - - init(type: FileType, url: URL, name: String) { - self.type = type - self.url = url - self.name = name - } -} - final class Importer { var posts: [String : PostFile] = [:] - var pages: [String : ImportedPage] = [:] + var pages: [String : PageOnDisk] = [:] var tags: [String : TagFile] = [:] - var files: [String : FileResource] = [:] + var files: [String : FileOnDisk] = [:] var ignoredFiles: [URL] = [] @@ -99,9 +44,9 @@ final class Importer { let meta = try JSONDecoder().decode(ImportableTag.self, from: data) let thumbnailUrl = folder.appending(path: "thumbnail.jpg", directoryHint: .notDirectory) - var thumbnail: FileResource? = nil + var thumbnail: FileOnDisk? = nil if FileManager.default.fileExists(atPath: thumbnailUrl.path()) { - thumbnail = FileResource(type: .image, url: thumbnailUrl, name: "\(name)-thumbnail.jpg") + thumbnail = FileOnDisk(type: .image, url: thumbnailUrl, name: "\(name)-thumbnail.jpg") add(resource: thumbnail!) } @@ -143,7 +88,7 @@ final class Importer { .filter { $0.hasDirectoryPath } } - private func findResources(in folder: URL, pageId: String) throws -> [FileResource] { + private func findResources(in folder: URL, pageId: String) throws -> [FileOnDisk] { try FileManager.default .contentsOfDirectory(at: folder, includingPropertiesForKeys: [.isDirectoryKey]) .filter { !$0.hasDirectoryPath } @@ -165,7 +110,7 @@ final class Importer { let name = pageId + "-" + fileName - return FileResource(type: type, url: url, name: name) + return FileOnDisk(type: type, url: url, name: name) } } @@ -272,7 +217,7 @@ final class Importer { posts[pageId] = post } - private func add(resource: FileResource) { + private func add(resource: FileOnDisk) { guard let existingFile = files[resource.name] else { files[resource.name] = resource return @@ -284,19 +229,19 @@ final class Importer { print("Conflicting name for file \(resource.name)") } - private func determineThumbnail(in resources: [FileResource], folder: URL, customPath: String?, pageId: String, language: String) throws -> FileResource? { + private func determineThumbnail(in resources: [FileOnDisk], folder: URL, customPath: String?, pageId: String, language: String) throws -> FileOnDisk? { guard let thumbnailUrl = findThumbnailUrl(in: folder, customPath: customPath, language: language) else { return nil } return resources.first { $0.url == thumbnailUrl } } - private func determineThumbnail(in folder: URL, customPath: String?, pageId: String, language: String) throws -> FileResource? { + private func determineThumbnail(in folder: URL, customPath: String?, pageId: String, language: String) throws -> FileOnDisk? { guard let thumbnailUrl = findThumbnailUrl(in: folder, customPath: customPath, language: language) else { return nil } let id = pageId + "-" + thumbnailUrl.lastPathComponent - return FileResource(image: id, url: thumbnailUrl) + return FileOnDisk(image: id, url: thumbnailUrl) } private func findThumbnailUrl(in folder: URL, customPath: String?, language: String) -> URL? { diff --git a/CHDataManagement/Model/Content.swift b/CHDataManagement/Model/Content.swift index e1e259b..edf3f14 100644 --- a/CHDataManagement/Model/Content.swift +++ b/CHDataManagement/Model/Content.swift @@ -17,7 +17,7 @@ final class Content: ObservableObject { var images: [ImageResource] = [] @Published - var files: [FileResources] = [] + var files: [FileResource] = [] @AppStorage("contentPath") private var storedContentPath: String = "" @@ -37,7 +37,7 @@ final class Content: ObservableObject { pages: [Page] = [], tags: [Tag] = [], images: [ImageResource] = [], - files: [FileResources] = [], + files: [FileResource] = [], storedContentPath: String) { self.posts = posts self.pages = pages @@ -209,13 +209,13 @@ final class Content: ObservableObject { dict[file] = ImageResource(uniqueId: file, altText: .init(en: "", de: ""), fileUrl: url) } - let files: [FileResources] = filesData.compactMap { file, url in + let files: [FileResource] = filesData.compactMap { file, url in let ext = file.components(separatedBy: ".").last!.lowercased() let type = FileType(fileExtension: ext) guard type == .file else { return nil } - return FileResources(uniqueId: file, description: "") + return FileResource(uniqueId: file, description: "") } let posts = postsData.map { postId, post in @@ -293,6 +293,27 @@ final class Content: ObservableObject { } } + // MARK: Saving + + func saveToDisk() { + print("Starting save") + for page in pages { + storage.save(pageMetadata: page.pageFile, for: page.id) + } + + for post in posts { + storage.save(post: post.postFile, for: post.id) + } + + for tag in tags { + storage.save(tagMetadata: tag.tagFile, for: tag.id) + } + // TODO: Remove all files that are no longer in use (they belong to deleted items) + print("Finished save") + } + + // MARK: Folder access + static func accessFolderFromBookmark(key: String, operation: (URL) -> Void) { guard let bookmarkData = UserDefaults.standard.data(forKey: key) else { print("No bookmark data to access folder") diff --git a/CHDataManagement/Model/FileResource.swift b/CHDataManagement/Model/FileResource.swift index fc5ca6f..88f51bf 100644 --- a/CHDataManagement/Model/FileResource.swift +++ b/CHDataManagement/Model/FileResource.swift @@ -1,6 +1,6 @@ import Foundation -final class FileResources: ObservableObject { +final class FileResource: ObservableObject { /// Globally unique id @Published diff --git a/CHDataManagement/Model/ImageResource.swift b/CHDataManagement/Model/ImageResource.swift index a0a5105..34ac443 100644 --- a/CHDataManagement/Model/ImageResource.swift +++ b/CHDataManagement/Model/ImageResource.swift @@ -50,6 +50,13 @@ extension ImageResource: Equatable { } } +extension ImageResource: Hashable { + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } +} + extension ImageResource { var imageToDisplay: Image { diff --git a/CHDataManagement/Model/LocalizedPost.swift b/CHDataManagement/Model/LocalizedPost.swift index b878485..fab9f47 100644 --- a/CHDataManagement/Model/LocalizedPost.swift +++ b/CHDataManagement/Model/LocalizedPost.swift @@ -25,10 +25,6 @@ final class LocalizedPost: ObservableObject { self.images = images } - var displayImages: [Image] { - images.map { $0.imageToDisplay } - } - @MainActor func editableTitle() -> Binding { Binding( diff --git a/CHDataManagement/Model/Post+Storage.swift b/CHDataManagement/Model/Post+Storage.swift new file mode 100644 index 0000000..3e090de --- /dev/null +++ b/CHDataManagement/Model/Post+Storage.swift @@ -0,0 +1,26 @@ +import Foundation + +extension Post { + + var postFile: PostFile { + .init( + isDraft: isDraft, + createdDate: createdDate, + startDate: startDate, + endDate: hasEndDate ? endDate : nil, + tags: tags.map { $0.id }, + german: german.postFile, + english: english.postFile, + linkedPageId: linkedPage?.id) + } +} + +extension LocalizedPost { + + var postFile: LocalizedPostFile { + .init(images: images.map { $0.id }, + title: title.nonEmpty, + content: content, + lastModifiedDate: lastModified) + } +} diff --git a/CHDataManagement/Model/Tag+Storage.swift b/CHDataManagement/Model/Tag+Storage.swift new file mode 100644 index 0000000..30c2760 --- /dev/null +++ b/CHDataManagement/Model/Tag+Storage.swift @@ -0,0 +1,22 @@ +import Foundation + +extension Tag { + + var tagFile: TagFile { + .init(id: id, + german: german.tagFile, + english: english.tagFile) + } +} + +extension LocalizedTag { + + var tagFile: LocalizedTagFile { + .init(urlComponent: urlComponent, + name: name, + subtitle: subtitle, + description: description, + thumbnail: thumbnail, + originalURL: originalUrl) + } +} diff --git a/CHDataManagement/Preview Content/Post+Mock.swift b/CHDataManagement/Preview Content/Post+Mock.swift index 9caeec6..0a3415b 100644 --- a/CHDataManagement/Preview Content/Post+Mock.swift +++ b/CHDataManagement/Preview Content/Post+Mock.swift @@ -33,15 +33,20 @@ extension Post { createdDate: .now, startDate: .now.addingTimeInterval(-86400), endDate: .now, tags: [.nature, .sports, .hiking, .mountains], - german: .init( - title: "Ein langer Titel", - content: "Sehr schöne und einsame Tour von Oberau zum Krottenkopf. Abwechslungsreich und stellenweise fordernd, aufgrund der Länge und der Höhenmeter auch anstrengend.", - images: MockImage.images), - english: .init( - title: "A longer title", - content: "Very nice and solitary hike from Oberau to Krottenkopf. Challenging and rewarding, due to the length and height.", - images: MockImage.images) - - ) + german: .german, + english: .english) } } + +extension LocalizedPost { + + static let german = LocalizedPost( + title: "Ein langer Titel", + content: "Sehr schöne und einsame Tour von Oberau zum Krottenkopf. Abwechslungsreich und stellenweise fordernd, aufgrund der Länge und der Höhenmeter auch anstrengend.", + images: MockImage.images) + + static let english = LocalizedPost( + title: "A longer title", + content: "Very nice and solitary hike from Oberau to Krottenkopf. Challenging and rewarding, due to the length and height.", + images: MockImage.images) +} diff --git a/CHDataManagement/Storage/FileOnDisk.swift b/CHDataManagement/Storage/FileOnDisk.swift new file mode 100644 index 0000000..f3f3ec0 --- /dev/null +++ b/CHDataManagement/Storage/FileOnDisk.swift @@ -0,0 +1,23 @@ +import Foundation + +struct FileOnDisk { + + let type: FileType + + let url: URL + + let name: String + + init(image: String, url: URL) { + self.type = .image + self.url = url + self.name = image + } + + init(type: FileType, url: URL, name: String) { + self.type = type + self.url = url + self.name = name + } +} + diff --git a/CHDataManagement/Storage/FileType.swift b/CHDataManagement/Storage/FileType.swift new file mode 100644 index 0000000..9cba02f --- /dev/null +++ b/CHDataManagement/Storage/FileType.swift @@ -0,0 +1,25 @@ +import Foundation + +enum FileType { + case image + case file + case video + case resource + + + init(fileExtension: String) { + switch fileExtension.lowercased() { + case "jpg", "jpeg", "png", "gif": + self = .image + case "html", "stl", "f3d", "step", "f3z", "zip", "json", "conf", "css", "js", "cpp", "cddx", "svg", "glb", "mp3", "pdf", "swift": + self = .file + case "mp4": + self = .video + case "key", "psd": + self = .resource + default: + print("Unhandled file type: \(fileExtension)") + self = .resource + } + } +} diff --git a/CHDataManagement/Storage/PageOnDisk.swift b/CHDataManagement/Storage/PageOnDisk.swift new file mode 100644 index 0000000..59a642c --- /dev/null +++ b/CHDataManagement/Storage/PageOnDisk.swift @@ -0,0 +1,11 @@ +import Foundation + +struct PageOnDisk { + + let page: PageFile + + let deContentUrl: URL + + let enContentUrl: URL +} + diff --git a/CHDataManagement/Storage/Storage.swift b/CHDataManagement/Storage/Storage.swift index 1aed20c..60379da 100644 --- a/CHDataManagement/Storage/Storage.swift +++ b/CHDataManagement/Storage/Storage.swift @@ -252,8 +252,28 @@ final class Storage { print("Failed to encode content of \(type) '\(id)': \(error)") return false } + return write(data: content, type: type, id: id, to: file) + } + + private func write(data: Data, type: String, id: String, to file: URL) -> Bool { + if fm.fileExists(atPath: file.path()) { + // Check if content is the same, to prevent unnecessary writes + do { + let oldData = try Data(contentsOf: file) + if data == oldData { + // File is the same, don't write + return true + } + } catch { + print("Failed to read file \(file.path()) for equality check: \(error)") + // No check possible, write file + } + } else { + print("Writing new file \(file.path())") + } do { - try content.write(to: file, options: .atomic) + try data.write(to: file, options: .atomic) + print("Saved file \(file.path())") return true } catch { print("Failed to save content for \(type) '\(id)': \(error)") @@ -272,13 +292,11 @@ final class Storage { } private func write(content: String, to file: URL, type: String, id: String) -> Bool { - do { - try content.write(to: file, atomically: true, encoding: .utf8) - return true - } catch { - print("Failed to save content for \(type) '\(id)': \(error)") + guard let data = content.data(using: .utf8) else { + print("Failed to convert string to data for \(type) '\(id)'") return false } + return write(data: data, type: type, id: id, to: file) } private func read(at url: URL) throws -> T where T: Decodable { @@ -293,5 +311,4 @@ final class Storage { items[id] = item } } - } diff --git a/CHDataManagement/Views/ColorPalette.swift b/CHDataManagement/Views/ColorPalette.swift index 9af96f6..3a105e8 100644 --- a/CHDataManagement/Views/ColorPalette.swift +++ b/CHDataManagement/Views/ColorPalette.swift @@ -2,16 +2,19 @@ import SwiftUI enum ColorPalette { - static let tagBackground = Color(r: 9, g: 62, b: 103) + static let tagBackground = Color(r: 188, g: 188, b: 188) // Color(r: 9, g: 62, b: 103) - static let tagForeground = Color(r: 96, g: 186, b: 255) + static let tagForeground = Color.primary // Color(r: 96, g: 186, b: 255) static let listBackground = Color(r: 2, g: 15, b: 26) - static let postBackground = Color(r: 4, g: 31, b: 52) + static let postBackground = Color(r: 222, g: 222, b: 222) // Color(r: 4, g: 31, b: 52) static let postText = Color(r: 221, g: 221, b: 221) static let postDate = tagForeground + + static let link = Color.blue + } diff --git a/CHDataManagement/Views/Generic/VerticalCenter.swift b/CHDataManagement/Views/Generic/VerticalCenter.swift new file mode 100644 index 0000000..b37f178 --- /dev/null +++ b/CHDataManagement/Views/Generic/VerticalCenter.swift @@ -0,0 +1,33 @@ +import SwiftUI + +/** + A view that centers the content vertically using a `VStack` + */ +struct VerticalCenter : View where Content : View { + + let alignment: HorizontalAlignment + + let spacing: CGFloat? + + let content: Content + + public init(alignment: HorizontalAlignment = .center, spacing: CGFloat? = nil, @ViewBuilder content: () -> Content) { + self.alignment = alignment + self.spacing = spacing + self.content = content() + } + + var body: some View { + VStack(alignment: alignment, spacing: spacing) { + Spacer() + content + Spacer() + } + } +} + +#Preview { + VerticalCenter { + Text("Test") + } +} diff --git a/CHDataManagement/Views/Posts/ImagePickerView.swift b/CHDataManagement/Views/Posts/ImagePickerView.swift new file mode 100644 index 0000000..9f94973 --- /dev/null +++ b/CHDataManagement/Views/Posts/ImagePickerView.swift @@ -0,0 +1,60 @@ +import SwiftUI + +struct ImagePickerView: View { + + @Binding + var showImagePicker: Bool + + @ObservedObject + var post: LocalizedPost + + @EnvironmentObject + private var content: Content + + @Environment(\.language) + private var language + + init(showImagePicker: Binding, post: LocalizedPost) { + self._showImagePicker = showImagePicker + self.post = post + } + + @State + private var selectedImage: ImageResource? + + var body: some View { + VStack { + Text("Select the image to add") + List(content.images, selection: $selectedImage) { image in + Text("\(image.id)") + .tag(image) + } + .frame(minHeight: 300) + HStack { + Button("Add") { + DispatchQueue.main.async { + if let selectedImage { + print("Added image") + post.images.append(selectedImage) + } else { + print("No image to add") + } + } + showImagePicker = false + } + .disabled(selectedImage == nil) + Button("Cancel", role: .cancel) { + showImagePicker = false + } + } + } + .navigationTitle("Pick a page") + .padding() + } +} + +#Preview { + ImagePickerView(showImagePicker: .constant(true), + post: LocalizedPost.english) + .environmentObject(Content.mock) +} diff --git a/CHDataManagement/Views/Posts/PostImageGalleryView.swift b/CHDataManagement/Views/Posts/PostImageGalleryView.swift index 6eb6b2c..78298c3 100644 --- a/CHDataManagement/Views/Posts/PostImageGalleryView.swift +++ b/CHDataManagement/Views/Posts/PostImageGalleryView.swift @@ -22,16 +22,45 @@ private struct NavigationIcon: View { struct PostImageGalleryView: View { - let images: [Image] + @ObservedObject + var post: LocalizedPost @State private var currentIndex = 0 + @State + private var showImagePicker = false + var body: some View { - ZStack { - images[currentIndex] - .resizable() - .scaledToFit() - if images.count > 1 { + ZStack(alignment: .center) { + ZStack(alignment: .bottomTrailing) { + ZStack(alignment: .bottom) { + post.images[currentIndex] + .imageToDisplay + .resizable() + .scaledToFit() + if post.images.count > 1 { + HStack(spacing: 8) { + ForEach(0.. 1 { HStack { Button(action: previous) { NavigationIcon(symbol: .chevronLeft, edge: .trailing) @@ -46,38 +75,33 @@ struct PostImageGalleryView: View { .buttonStyle(.plain) .padding() } - VStack { - Spacer() - HStack(spacing: 8) { - ForEach(0.. 0 { currentIndex -= 1 } else { - currentIndex = images.count - 1 + currentIndex = post.images.count - 1 } } private func next() { - if currentIndex < images.count - 1 { + if currentIndex < post.images.count - 1 { currentIndex += 1 } else { - currentIndex = 0 // Wrap to first image + currentIndex = 0 } } } -#Preview(traits: .fixedLayout(width: 300, height: 300)) { - PostImageGalleryView(images: MockImage.images.map { $0.imageToDisplay }) +#Preview(traits: .fixedLayout(width: 300, height: 250)) { + PostImageGalleryView(post: .german) } diff --git a/CHDataManagement/Views/Posts/PostView.swift b/CHDataManagement/Views/Posts/PostView.swift index 82c14ff..f0df198 100644 --- a/CHDataManagement/Views/Posts/PostView.swift +++ b/CHDataManagement/Views/Posts/PostView.swift @@ -14,6 +14,12 @@ struct PostView: View { @State private var showPagePicker = false + @State + private var showImagePicker = false + + @State + private var showTagPicker = false + private var linkedPageText: String { if let page = post.linkedPage { return page.localized(in: language).title @@ -23,8 +29,15 @@ struct PostView: View { var body: some View { VStack(alignment: .center) { - if !post.localized(in: language).images.isEmpty { - PostImageGalleryView(images: post.localized(in: language).displayImages) + if post.localized(in: language).images.isEmpty { + Button(action: { showImagePicker = true }) { + Text("Add image") + } + .buttonStyle(.plain) + .foregroundStyle(.blue) + .padding(.top) + } else { + PostImageGalleryView(post: post.localized(in: language)) .aspectRatio(1.33, contentMode: .fill) } VStack(alignment: .leading) { @@ -35,10 +48,10 @@ struct PostView: View { Spacer() Toggle("Draft", isOn: $post.isDraft) } - .foregroundStyle(ColorPalette.postDate) + .foregroundStyle(.secondary) TextField("", text: post.localized(in: language).editableTitle()) .font(.system(size: 24, weight: .bold)) - .foregroundStyle(Color.white) + .foregroundStyle(Color.primary) .textFieldStyle(.plain) .lineLimit(2) FlowHStack { @@ -51,20 +64,20 @@ struct PostView: View { remove(tag: tag) } } - Button(action: showTagList) { + Button(action: { showTagPicker = true }) { SwiftUI.Image(systemSymbol: .plusCircleFill) .resizable() .aspectRatio(1, contentMode: .fit) .frame(height: 18) - .foregroundColor(ColorPalette.tagForeground) - .opacity(0.7) + .foregroundColor(Color.blue) + //.opacity(0.7) .padding(.top, 3) } .buttonStyle(.plain) } TextEditor(text: post.localized(in: language).editableContent()) .font(.body) - .foregroundStyle(ColorPalette.postText) + .foregroundStyle(.secondary) .textEditorStyle(.plain) .padding(.leading, -5) .scrollDisabled(true) @@ -73,12 +86,12 @@ struct PostView: View { Text(linkedPageText) } .buttonStyle(.plain) - .foregroundStyle(ColorPalette.postDate) + .foregroundStyle(ColorPalette.link) } } .padding() } - .background(ColorPalette.postBackground) + .background(Color.secondary.colorInvert()) .cornerRadius(8) .sheet(isPresented: $showDatePicker) { DatePickerView( @@ -90,26 +103,28 @@ struct PostView: View { showPagePicker: $showPagePicker, selectedPage: $post.linkedPage) } + .sheet(isPresented: $showImagePicker) { + ImagePickerView( + showImagePicker: $showImagePicker, + post: post.localized(in: language) + ) + } } private func remove(tag: Tag) { post.tags = post.tags.filter {$0.id != tag.id } } - - private func showTagList() { - - } } #Preview(traits: .fixedLayout(width: 450, height: 600)) { List { PostView(post: .fullMock) .listRowSeparator(.hidden) - .listRowBackground(ColorPalette.listBackground) + //.listRowBackground(ColorPalette.listBackground) .environment(\.language, ContentLanguage.german) PostView(post: .mock) .listRowSeparator(.hidden) - .listRowBackground(ColorPalette.listBackground) + //.listRowBackground(ColorPalette.listBackground) } - .listStyle(.plain) + //.listStyle(.plain) } diff --git a/CHDataManagement/Views/Posts/TagView.swift b/CHDataManagement/Views/Posts/TagView.swift index 5fe39bd..7c88008 100644 --- a/CHDataManagement/Views/Posts/TagView.swift +++ b/CHDataManagement/Views/Posts/TagView.swift @@ -9,18 +9,12 @@ struct TagView: View { let tag: LocalizedText - let icon: SFSymbol - - let iconSize: CGFloat - - init(tag: LocalizedText, icon: SFSymbol = .xCircleFill, iconSize: CGFloat = 12.0) { + init(tag: LocalizedText) { self.tag = tag - self.icon = icon - self.iconSize = iconSize } static var add: TagView { - .init(tag: LocalizedText(en: "Add", de: "Mehr"), icon: .plusCircleFill) + .init(tag: LocalizedText(en: "Add", de: "Mehr")) } var body: some View { @@ -28,16 +22,11 @@ struct TagView: View { Text(tag.getText(for: language)) .font(.subheadline) .padding(.leading, 2) - SwiftUI.Image(systemSymbol: icon) - .font(.system(size: iconSize, weight: .black, design: .rounded)) - .opacity(0.7) - .padding(.leading, -5) } - .foregroundColor(ColorPalette.tagForeground) .font(.caption2) .padding(.horizontal, 8) .padding(.vertical, 4) - .background(ColorPalette.tagBackground) + .background(Color.accentColor) .cornerRadius(8) } } @@ -49,5 +38,5 @@ struct TagView: View { TagView(tag: LocalizedText(en: "Some", de: "Etwas")) .environment(\.language, ContentLanguage.english) TagView.add - } + }.background(Color.secondary) }