diff --git a/CHDataManagement.xcodeproj/project.pbxproj b/CHDataManagement.xcodeproj/project.pbxproj index 6e3c989..cca0114 100644 --- a/CHDataManagement.xcodeproj/project.pbxproj +++ b/CHDataManagement.xcodeproj/project.pbxproj @@ -7,29 +7,17 @@ objects = { /* Begin PBXBuildFile section */ - E227BE282C3330CE00F0CB47 /* Article.swift in Sources */ = {isa = PBXBuildFile; fileRef = E227BE272C3330CE00F0CB47 /* Article.swift */; }; - E227BE2A2C355AF700F0CB47 /* Use.swift in Sources */ = {isa = PBXBuildFile; fileRef = E227BE292C355AF700F0CB47 /* Use.swift */; }; - E227BE2D2C3E976D00F0CB47 /* Path.swift in Sources */ = {isa = PBXBuildFile; fileRef = E227BE2C2C3E976D00F0CB47 /* Path.swift */; }; - E227BE2F2C3E97DF00F0CB47 /* Svg+Dimensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E227BE2E2C3E97DF00F0CB47 /* Svg+Dimensions.swift */; }; - E227BE312C3E9B2700F0CB47 /* MetricIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = E227BE302C3E9B2700F0CB47 /* MetricIcon.swift */; }; - E227BE332C3EA51500F0CB47 /* TagLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = E227BE322C3EA51500F0CB47 /* TagLink.swift */; }; - E227BE352C415EC000F0CB47 /* Source+Attributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = E227BE342C415EC000F0CB47 /* Source+Attributes.swift */; }; - E227BE372C415F8900F0CB47 /* Image+Attributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = E227BE362C415F8900F0CB47 /* Image+Attributes.swift */; }; - E227BE392C41611100F0CB47 /* ArticleImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = E227BE382C41611100F0CB47 /* ArticleImage.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 */; }; E24252082C5168750029FF16 /* GenericMetadata+Localized.swift in Sources */ = {isa = PBXBuildFile; fileRef = E24252072C5168750029FF16 /* GenericMetadata+Localized.swift */; }; E242520A2C52C9260029FF16 /* ContentLanguage.swift in Sources */ = {isa = PBXBuildFile; fileRef = E24252092C52C9260029FF16 /* ContentLanguage.swift */; }; E2581DED2C75202400F1F079 /* Tag.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2581DEC2C75202400F1F079 /* Tag.swift */; }; - E2581DEF2C75203800F1F079 /* TagsSubtitle.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2581DEE2C75203800F1F079 /* TagsSubtitle.swift */; }; E2581DF12C7523F400F1F079 /* ImportableTag.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2581DF02C7523F400F1F079 /* ImportableTag.swift */; }; - E28101192C50E03A0066F5BE /* EntryContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E28101182C50E03A0066F5BE /* EntryContentView.swift */; }; E2A21C012CB16A820060935B /* PostView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A21C002CB16A820060935B /* PostView.swift */; }; E2A21C032CB16C290060935B /* Environment+Language.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A21C022CB16C220060935B /* Environment+Language.swift */; }; E2A21C052CB1766C0060935B /* LocalizedText.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A21C042CB176670060935B /* LocalizedText.swift */; }; E2A21C082CB17B870060935B /* TagView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A21C072CB17B810060935B /* TagView.swift */; }; - E2A21C0C2CB17C190060935B /* TagListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A21C0B2CB17C150060935B /* TagListView.swift */; }; E2A21C0E2CB189DC0060935B /* Color+RGB.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A21C0D2CB189D70060935B /* Color+RGB.swift */; }; E2A21C102CB18B3A0060935B /* FlowHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A21C0F2CB18B390060935B /* FlowHStack.swift */; }; E2A21C122CB18D560060935B /* DatePickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A21C112CB18D520060935B /* DatePickerView.swift */; }; @@ -42,24 +30,20 @@ E2A21C332CB5BCAC0060935B /* PageDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A21C312CB5BCAC0060935B /* PageDetailView.swift */; }; E2A21C362CB9A3D70060935B /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A21C352CB9A3D70060935B /* SettingsView.swift */; }; E2A21C3B2CB9D9A60060935B /* ImageResource.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A21C3A2CB9D9A50060935B /* ImageResource.swift */; }; - E2A21C3E2CBA53860060935B /* Elementary in Frameworks */ = {isa = PBXBuildFile; productRef = E2A21C3D2CBA53860060935B /* Elementary */; }; - E2A21C412CBA53FA0060935B /* Elementary in Frameworks */ = {isa = PBXBuildFile; productRef = E2A21C402CBA53FA0060935B /* Elementary */; }; - E2A21C442CBA560F0060935B /* Elementary in Frameworks */ = {isa = PBXBuildFile; productRef = E2A21C432CBA560F0060935B /* Elementary */; }; E2A21C462CBAE2E60060935B /* FeedEntryContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A21C452CBAE2E50060935B /* FeedEntryContent.swift */; }; E2A21C482CBAF88B0060935B /* String+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A21C472CBAF8830060935B /* String+Extensions.swift */; }; E2A21C4D2CBB16B50060935B /* ImagesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A21C4C2CBB16B50060935B /* ImagesView.swift */; }; E2A21C4F2CBB29E50060935B /* ImageDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A21C4E2CBB29E50060935B /* ImageDetailsView.swift */; }; E2A21C512CBBD53F0060935B /* FileResource.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A21C502CBBD53C0060935B /* FileResource.swift */; }; + E2A21C542CBBF87A0060935B /* FilesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A21C532CBBF87A0060935B /* FilesView.swift */; }; + E2A21C562CBBF9880060935B /* FlexibleColumnView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A21C552CBBF9880060935B /* FlexibleColumnView.swift */; }; E2A9CB7E2C7BCF2A005C89CC /* Page.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A9CB7D2C7BCF2A005C89CC /* Page.swift */; }; E2B85F362C426BEE0047CD0C /* SFSafeSymbols in Frameworks */ = {isa = PBXBuildFile; productRef = E2B85F352C426BEE0047CD0C /* SFSafeSymbols */; }; - E2B85F382C4289F10047CD0C /* TopNavigationBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2B85F372C4289F10047CD0C /* TopNavigationBar.swift */; }; E2B85F3B2C428F0E0047CD0C /* Post.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2B85F3A2C428F0D0047CD0C /* Post.swift */; }; E2B85F3D2C4293F80047CD0C /* Feed.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2B85F3C2C4293F80047CD0C /* Feed.swift */; }; E2B85F412C4294790047CD0C /* PageHead.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2B85F402C4294790047CD0C /* PageHead.swift */; }; E2B85F432C4294F60047CD0C /* FeedEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2B85F422C4294F60047CD0C /* FeedEntry.swift */; }; E2B85F452C429ED60047CD0C /* ImageGallery.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2B85F442C429ED60047CD0C /* ImageGallery.swift */; }; - E2B85F522C4BB3220047CD0C /* OptionalTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2B85F512C4BB3220047CD0C /* OptionalTextField.swift */; }; - E2B85F542C4BCCAC0047CD0C /* DetailTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2B85F532C4BCCAC0047CD0C /* DetailTextField.swift */; }; E2B85F572C4BD0BB0047CD0C /* Binding+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2B85F562C4BD0BB0047CD0C /* Binding+Extension.swift */; }; E2DD04742C276F31003BFF1F /* CHDataManagementApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2DD04732C276F31003BFF1F /* CHDataManagementApp.swift */; }; E2DD047A2C276F32003BFF1F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = E2DD04792C276F32003BFF1F /* Assets.xcassets */; }; @@ -69,28 +53,16 @@ /* End PBXBuildFile section */ /* Begin PBXFileReference section */ - E227BE272C3330CE00F0CB47 /* Article.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Article.swift; sourceTree = ""; }; - E227BE292C355AF700F0CB47 /* Use.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Use.swift; sourceTree = ""; }; - E227BE2C2C3E976D00F0CB47 /* Path.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Path.swift; sourceTree = ""; }; - E227BE2E2C3E97DF00F0CB47 /* Svg+Dimensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Svg+Dimensions.swift"; sourceTree = ""; }; - E227BE302C3E9B2700F0CB47 /* MetricIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricIcon.swift; sourceTree = ""; }; - E227BE322C3EA51500F0CB47 /* TagLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagLink.swift; sourceTree = ""; }; - E227BE342C415EC000F0CB47 /* Source+Attributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Source+Attributes.swift"; sourceTree = ""; }; - E227BE362C415F8900F0CB47 /* Image+Attributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Image+Attributes.swift"; sourceTree = ""; }; - E227BE382C41611100F0CB47 /* ArticleImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArticleImage.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 = ""; }; E24252092C52C9260029FF16 /* ContentLanguage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentLanguage.swift; sourceTree = ""; }; E2581DEC2C75202400F1F079 /* Tag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tag.swift; sourceTree = ""; }; - E2581DEE2C75203800F1F079 /* TagsSubtitle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagsSubtitle.swift; sourceTree = ""; }; E2581DF02C7523F400F1F079 /* ImportableTag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImportableTag.swift; sourceTree = ""; }; - E28101182C50E03A0066F5BE /* EntryContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EntryContentView.swift; sourceTree = ""; }; E2A21C002CB16A820060935B /* PostView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostView.swift; sourceTree = ""; }; E2A21C022CB16C220060935B /* Environment+Language.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Environment+Language.swift"; sourceTree = ""; }; E2A21C042CB176670060935B /* LocalizedText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizedText.swift; sourceTree = ""; }; E2A21C072CB17B810060935B /* TagView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagView.swift; sourceTree = ""; }; - E2A21C0B2CB17C150060935B /* TagListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagListView.swift; sourceTree = ""; }; E2A21C0D2CB189D70060935B /* Color+RGB.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+RGB.swift"; sourceTree = ""; }; E2A21C0F2CB18B390060935B /* FlowHStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlowHStack.swift; sourceTree = ""; }; E2A21C112CB18D520060935B /* DatePickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatePickerView.swift; sourceTree = ""; }; @@ -108,15 +80,14 @@ E2A21C4C2CBB16B50060935B /* ImagesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagesView.swift; sourceTree = ""; }; E2A21C4E2CBB29E50060935B /* ImageDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageDetailsView.swift; sourceTree = ""; }; E2A21C502CBBD53C0060935B /* FileResource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileResource.swift; sourceTree = ""; }; + E2A21C532CBBF87A0060935B /* FilesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilesView.swift; sourceTree = ""; }; + E2A21C552CBBF9880060935B /* FlexibleColumnView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlexibleColumnView.swift; sourceTree = ""; }; E2A9CB7D2C7BCF2A005C89CC /* Page.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Page.swift; sourceTree = ""; }; - E2B85F372C4289F10047CD0C /* TopNavigationBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TopNavigationBar.swift; sourceTree = ""; }; E2B85F3A2C428F0D0047CD0C /* Post.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Post.swift; sourceTree = ""; }; E2B85F3C2C4293F80047CD0C /* Feed.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Feed.swift; sourceTree = ""; }; E2B85F402C4294790047CD0C /* PageHead.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageHead.swift; sourceTree = ""; }; E2B85F422C4294F60047CD0C /* FeedEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedEntry.swift; sourceTree = ""; }; E2B85F442C429ED60047CD0C /* ImageGallery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageGallery.swift; sourceTree = ""; }; - E2B85F512C4BB3220047CD0C /* OptionalTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptionalTextField.swift; sourceTree = ""; }; - E2B85F532C4BCCAC0047CD0C /* DetailTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailTextField.swift; sourceTree = ""; }; E2B85F562C4BD0BB0047CD0C /* Binding+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Binding+Extension.swift"; sourceTree = ""; }; E2DD04702C276F31003BFF1F /* CHDataManagement.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CHDataManagement.app; sourceTree = BUILT_PRODUCTS_DIR; }; E2DD04732C276F31003BFF1F /* CHDataManagementApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CHDataManagementApp.swift; sourceTree = ""; }; @@ -132,9 +103,6 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - E2A21C412CBA53FA0060935B /* Elementary in Frameworks */, - E2A21C3E2CBA53860060935B /* Elementary in Frameworks */, - E2A21C442CBA560F0060935B /* Elementary in Frameworks */, E2B85F362C426BEE0047CD0C /* SFSafeSymbols in Frameworks */, E24252012C50E0A40029FF16 /* HighlightedTextEditor in Frameworks */, ); @@ -143,52 +111,17 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ - E227BE262C3330C100F0CB47 /* Elementary */ = { - isa = PBXGroup; - children = ( - E227BE2B2C3E976000F0CB47 /* Custom Elements */, - E227BE302C3E9B2700F0CB47 /* MetricIcon.swift */, - E227BE322C3EA51500F0CB47 /* TagLink.swift */, - E2581DEE2C75203800F1F079 /* TagsSubtitle.swift */, - ); - path = Elementary; - sourceTree = ""; - }; - E227BE2B2C3E976000F0CB47 /* Custom Elements */ = { - isa = PBXGroup; - children = ( - E227BE292C355AF700F0CB47 /* Use.swift */, - E227BE2C2C3E976D00F0CB47 /* Path.swift */, - E227BE2E2C3E97DF00F0CB47 /* Svg+Dimensions.swift */, - E227BE342C415EC000F0CB47 /* Source+Attributes.swift */, - E227BE362C415F8900F0CB47 /* Image+Attributes.swift */, - E227BE382C41611100F0CB47 /* ArticleImage.swift */, - E2B85F372C4289F10047CD0C /* TopNavigationBar.swift */, - ); - path = "Custom Elements"; - sourceTree = ""; - }; E24252042C5168430029FF16 /* Import */ = { isa = PBXGroup; children = ( E24252022C5163CF0029FF16 /* Importer.swift */, + E2581DF02C7523F400F1F079 /* ImportableTag.swift */, E24252052C51684E0029FF16 /* GenericMetadata.swift */, E24252072C5168750029FF16 /* GenericMetadata+Localized.swift */, ); path = Import; sourceTree = ""; }; - E2A21C062CB17B7A0060935B /* Unused */ = { - isa = PBXGroup; - children = ( - E2A21C0B2CB17C150060935B /* TagListView.swift */, - E2B85F512C4BB3220047CD0C /* OptionalTextField.swift */, - E2B85F532C4BCCAC0047CD0C /* DetailTextField.swift */, - E28101182C50E03A0066F5BE /* EntryContentView.swift */, - ); - path = Unused; - sourceTree = ""; - }; E2A21C322CB5BCAC0060935B /* Pages */ = { isa = PBXGroup; children = ( @@ -219,10 +152,19 @@ children = ( E2A21C4C2CBB16B50060935B /* ImagesView.swift */, E2A21C4E2CBB29E50060935B /* ImageDetailsView.swift */, + E2A21C552CBBF9880060935B /* FlexibleColumnView.swift */, ); path = Images; sourceTree = ""; }; + E2A21C522CBBF86D0060935B /* Files */ = { + isa = PBXGroup; + children = ( + E2A21C532CBBF87A0060935B /* FilesView.swift */, + ); + path = Files; + sourceTree = ""; + }; E2A9CB7F2C7E686C005C89CC /* Tags */ = { isa = PBXGroup; children = ( @@ -233,15 +175,14 @@ E2B85F392C428F020047CD0C /* Model */ = { isa = PBXGroup; children = ( + E2E06DFA2CA4A6570019C2AF /* Content.swift */, + E24252092C52C9260029FF16 /* ContentLanguage.swift */, E2A21C502CBBD53C0060935B /* FileResource.swift */, E2A21C3A2CB9D9A50060935B /* ImageResource.swift */, E2A21C042CB176670060935B /* LocalizedText.swift */, - E2E06DFA2CA4A6570019C2AF /* Content.swift */, - E2B85F3A2C428F0D0047CD0C /* Post.swift */, E2A9CB7D2C7BCF2A005C89CC /* Page.swift */, - E24252092C52C9260029FF16 /* ContentLanguage.swift */, + E2B85F3A2C428F0D0047CD0C /* Post.swift */, E2581DEC2C75202400F1F079 /* Tag.swift */, - E2581DF02C7523F400F1F079 /* ImportableTag.swift */, ); path = Model; sourceTree = ""; @@ -250,7 +191,6 @@ isa = PBXGroup; children = ( E2B85F3C2C4293F80047CD0C /* Feed.swift */, - E227BE272C3330CE00F0CB47 /* Article.swift */, ); path = Pages; sourceTree = ""; @@ -270,6 +210,7 @@ E2B85F462C42C7CA0047CD0C /* Views */ = { isa = PBXGroup; children = ( + E2A21C522CBBF86D0060935B /* Files */, E2A21C492CBB168F0060935B /* Images */, E2A21C372CB9A4F10060935B /* Generic */, E2A21C342CB9A3CA0060935B /* Settings */, @@ -322,13 +263,11 @@ E2DD04722C276F31003BFF1F /* CHDataManagement */ = { isa = PBXGroup; children = ( - E2A21C062CB17B7A0060935B /* Unused */, E2DD04732C276F31003BFF1F /* CHDataManagementApp.swift */, E2B85F392C428F020047CD0C /* Model */, E2B85F462C42C7CA0047CD0C /* Views */, E2B85F3F2C42946E0047CD0C /* Page Elements */, E2B85F3E2C4293FF0047CD0C /* Pages */, - E227BE262C3330C100F0CB47 /* Elementary */, E2DD04792C276F32003BFF1F /* Assets.xcassets */, E2DD047B2C276F32003BFF1F /* CHDataManagement.entitlements */, E2B85F552C4BD0AD0047CD0C /* Extensions */, @@ -368,9 +307,6 @@ packageProductDependencies = ( E2B85F352C426BEE0047CD0C /* SFSafeSymbols */, E24252002C50E0A40029FF16 /* HighlightedTextEditor */, - E2A21C3D2CBA53860060935B /* Elementary */, - E2A21C402CBA53FA0060935B /* Elementary */, - E2A21C432CBA560F0060935B /* Elementary */, ); productName = CHDataManagement; productReference = E2DD04702C276F31003BFF1F /* CHDataManagement.app */; @@ -403,7 +339,6 @@ packageReferences = ( E2B85F342C426BED0047CD0C /* XCRemoteSwiftPackageReference "SFSafeSymbols" */, E24251FF2C50E0A40029FF16 /* XCRemoteSwiftPackageReference "HighlightedTextEditor" */, - E2A21C422CBA560F0060935B /* XCRemoteSwiftPackageReference "elementary" */, ); productRefGroup = E2DD04712C276F31003BFF1F /* Products */; projectDirPath = ""; @@ -432,57 +367,44 @@ buildActionMask = 2147483647; files = ( E2A21C162CB1A3C90060935B /* PostImageGalleryView.swift in Sources */, + E2A21C562CBBF9880060935B /* FlexibleColumnView.swift in Sources */, E24252062C51684E0029FF16 /* GenericMetadata.swift in Sources */, E2A21C462CBAE2E60060935B /* FeedEntryContent.swift in Sources */, E242520A2C52C9260029FF16 /* ContentLanguage.swift in Sources */, E2B85F452C429ED60047CD0C /* ImageGallery.swift in Sources */, E2B85F3B2C428F0E0047CD0C /* Post.swift in Sources */, - E227BE332C3EA51500F0CB47 /* TagLink.swift in Sources */, E2A21C082CB17B870060935B /* TagView.swift in Sources */, - E2A21C0C2CB17C190060935B /* TagListView.swift in Sources */, - E2B85F542C4BCCAC0047CD0C /* DetailTextField.swift in Sources */, E2A21C282CB29B2A0060935B /* FeedEntryData.swift in Sources */, E2581DF12C7523F400F1F079 /* ImportableTag.swift in Sources */, E2A21C102CB18B3A0060935B /* FlowHStack.swift in Sources */, E2B85F3D2C4293F80047CD0C /* Feed.swift in Sources */, - E227BE312C3E9B2700F0CB47 /* MetricIcon.swift in Sources */, E2A21C2A2CB2AA4F0060935B /* Post+Mock.swift in Sources */, E24252082C5168750029FF16 /* GenericMetadata+Localized.swift in Sources */, E2581DED2C75202400F1F079 /* Tag.swift in Sources */, - E227BE372C415F8900F0CB47 /* Image+Attributes.swift in Sources */, E2A21C4F2CBB29E50060935B /* ImageDetailsView.swift in Sources */, E2A21C032CB16C290060935B /* Environment+Language.swift in Sources */, - E227BE282C3330CE00F0CB47 /* Article.swift in Sources */, E24252032C5163CF0029FF16 /* Importer.swift in Sources */, E2A21C332CB5BCAC0060935B /* PageDetailView.swift in Sources */, E2A21C122CB18D560060935B /* DatePickerView.swift in Sources */, E2A21C4D2CBB16B50060935B /* ImagesView.swift in Sources */, E2A21C202CB28ED20060935B /* MockImage.swift in Sources */, - E2B85F382C4289F10047CD0C /* TopNavigationBar.swift in Sources */, E2A21C2C2CB2BB250060935B /* PostList.swift in Sources */, - E2581DEF2C75203800F1F079 /* TagsSubtitle.swift in Sources */, E2B85F412C4294790047CD0C /* PageHead.swift in Sources */, E2A9CB7E2C7BCF2A005C89CC /* Page.swift in Sources */, E2E06DFB2CA4A65E0019C2AF /* Content.swift in Sources */, E2A21C3B2CB9D9A60060935B /* ImageResource.swift in Sources */, - E227BE2F2C3E97DF00F0CB47 /* Svg+Dimensions.swift in Sources */, E2A21C302CB490F90060935B /* HorizontalCenter.swift in Sources */, - E28101192C50E03A0066F5BE /* EntryContentView.swift in Sources */, E2DD04742C276F31003BFF1F /* CHDataManagementApp.swift in Sources */, E2A21C482CBAF88B0060935B /* String+Extensions.swift in Sources */, - E227BE352C415EC000F0CB47 /* Source+Attributes.swift in Sources */, - E227BE2D2C3E976D00F0CB47 /* Path.swift in Sources */, E2A21C512CBBD53F0060935B /* FileResource.swift in Sources */, + E2A21C542CBBF87A0060935B /* FilesView.swift in Sources */, E2E06E002CA4A8F00019C2AF /* Page+Mock.swift in Sources */, E2B85F572C4BD0BB0047CD0C /* Binding+Extension.swift in Sources */, E2B85F432C4294F60047CD0C /* FeedEntry.swift in Sources */, - E227BE2A2C355AF700F0CB47 /* Use.swift in Sources */, - E2B85F522C4BB3220047CD0C /* OptionalTextField.swift in Sources */, E2A21C0E2CB189DC0060935B /* Color+RGB.swift in Sources */, E2A21C362CB9A3D70060935B /* SettingsView.swift in Sources */, E2A21C012CB16A820060935B /* PostView.swift in Sources */, E2A21C052CB1766C0060935B /* LocalizedText.swift in Sources */, - E227BE392C41611100F0CB47 /* ArticleImage.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -715,14 +637,6 @@ minimumVersion = 2.1.2; }; }; - E2A21C422CBA560F0060935B /* XCRemoteSwiftPackageReference "elementary" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/sliemeobn/elementary"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 0.3.4; - }; - }; E2B85F342C426BED0047CD0C /* XCRemoteSwiftPackageReference "SFSafeSymbols" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/SFSafeSymbols/SFSafeSymbols"; @@ -739,19 +653,6 @@ package = E24251FF2C50E0A40029FF16 /* XCRemoteSwiftPackageReference "HighlightedTextEditor" */; productName = HighlightedTextEditor; }; - E2A21C3D2CBA53860060935B /* Elementary */ = { - isa = XCSwiftPackageProductDependency; - productName = Elementary; - }; - E2A21C402CBA53FA0060935B /* Elementary */ = { - isa = XCSwiftPackageProductDependency; - productName = Elementary; - }; - E2A21C432CBA560F0060935B /* Elementary */ = { - isa = XCSwiftPackageProductDependency; - package = E2A21C422CBA560F0060935B /* XCRemoteSwiftPackageReference "elementary" */; - productName = Elementary; - }; E2B85F352C426BEE0047CD0C /* SFSafeSymbols */ = { isa = XCSwiftPackageProductDependency; package = E2B85F342C426BED0047CD0C /* XCRemoteSwiftPackageReference "SFSafeSymbols" */; diff --git a/CHDataManagement.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/CHDataManagement.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index c6919d0..571a850 100644 --- a/CHDataManagement.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/CHDataManagement.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,15 +1,6 @@ { - "originHash" : "c1a67d708d6f681f2c183d65d661dd3b41db4b2eb186a732bdf66ec00610d102", + "originHash" : "a865991f5fa01ecfb2e7afd44ef74d1e86f52c8f7eec6be4e188382e4051b34c", "pins" : [ - { - "identity" : "elementary", - "kind" : "remoteSourceControl", - "location" : "https://github.com/sliemeobn/elementary", - "state" : { - "revision" : "5ed7c2d87190cf73cf4fd2df28be5ee6695af30d", - "version" : "0.3.4" - } - }, { "identity" : "highlightedtexteditor", "kind" : "remoteSourceControl", diff --git a/CHDataManagement/CHDataManagementApp.swift b/CHDataManagement/CHDataManagementApp.swift index 4b36470..c8bb0dd 100644 --- a/CHDataManagement/CHDataManagementApp.swift +++ b/CHDataManagement/CHDataManagementApp.swift @@ -1,32 +1,71 @@ -// -// CHDataManagementApp.swift -// CHDataManagement -// -// Created by CH on 22.06.24. -// - import SwiftUI -import SwiftData +import SFSafeSymbols + + +enum ContentDisplayType { + case markdown + case html + case rendered +} @main struct CHDataManagementApp: App { - var sharedModelContainer: ModelContainer = { - let schema = Schema([ - Item.self, - ]) - let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false) - do { - return try ModelContainer(for: schema, configurations: [modelConfiguration]) - } catch { - fatalError("Could not create ModelContainer: \(error)") - } - }() + var navigationTitle: String { + "" + } + + @ObservedObject + var content: Content = .init() + + @State + var selectedLanguage: ContentLanguage = .english + + @State + var contentDisplayType: ContentDisplayType = .markdown var body: some Scene { WindowGroup { - ContentView() + TabView { + Tab("Posts", systemImage: SFSymbol.rectangleAndPencilAndEllipsis.rawValue) { + PostList(posts: $content.posts) + .environment(\.language, selectedLanguage) + .background(Color(r: 2, g: 15, b: 26)) + } + Tab("Pages", systemImage: SFSymbol.textBelowPhoto.rawValue) { + Text("TODO") + } + Tab("Tags", systemImage: SFSymbol.tag.rawValue) { + Text("TODO") + } + Tab("Images", systemImage: SFSymbol.photo.rawValue) { + ImagesView() + .environmentObject(content) + } + Tab("Files", systemImage: SFSymbol.doc.rawValue) { + FilesView() + .environmentObject(content) + } + Tab("Settings", systemImage: SFSymbol.gear.rawValue) { + SettingsView() + .environmentObject(content) + } + } + .toolbar { + ToolbarItem(placement: .primaryAction) { + Picker("", selection: $selectedLanguage) { + Text("English") + .tag(ContentLanguage.english) + Text("German") + .tag(ContentLanguage.german) + }.pickerStyle(.segmented) + } + } + .onAppear(perform: importOldContent) } - .modelContainer(sharedModelContainer) + } + + private func importOldContent() { + content.importOldContent() } } diff --git a/CHDataManagement/ContentView.swift b/CHDataManagement/ContentView.swift deleted file mode 100644 index 5a3f8fa..0000000 --- a/CHDataManagement/ContentView.swift +++ /dev/null @@ -1,66 +0,0 @@ -// -// ContentView.swift -// CHDataManagement -// -// Created by CH on 22.06.24. -// - -import SwiftUI -import SwiftData - -struct ContentView: View { - @Environment(\.modelContext) private var modelContext - @Query private var items: [Item] - - var body: some View { - NavigationSplitView { - List { - ForEach(items) { item in - NavigationLink { - Text("Item at \(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))") - } label: { - Text(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard)) - } - } - .onDelete(perform: deleteItems) - } -#if os(macOS) - .navigationSplitViewColumnWidth(min: 180, ideal: 200) -#endif - .toolbar { -#if os(iOS) - ToolbarItem(placement: .navigationBarTrailing) { - EditButton() - } -#endif - ToolbarItem { - Button(action: addItem) { - Label("Add Item", systemImage: "plus") - } - } - } - } detail: { - Text("Select an item") - } - } - - private func addItem() { - withAnimation { - let newItem = Item(timestamp: Date()) - modelContext.insert(newItem) - } - } - - private func deleteItems(offsets: IndexSet) { - withAnimation { - for index in offsets { - modelContext.delete(items[index]) - } - } - } -} - -#Preview { - ContentView() - .modelContainer(for: Item.self, inMemory: true) -} diff --git a/CHDataManagement/Extensions/Binding+Extension.swift b/CHDataManagement/Extensions/Binding+Extension.swift new file mode 100644 index 0000000..1e5e616 --- /dev/null +++ b/CHDataManagement/Extensions/Binding+Extension.swift @@ -0,0 +1,14 @@ +import SwiftUI + +public extension Binding where Value: Equatable, Value: Sendable { + + init(_ source: Binding, replacingNilWith nilProxy: Value) { + self.init( + get: { source.wrappedValue ?? nilProxy }, + set: { newValue in + if newValue == nilProxy { source.wrappedValue = nil } + else { source.wrappedValue = newValue } + } + ) + } +} diff --git a/CHDataManagement/Extensions/Color+RGB.swift b/CHDataManagement/Extensions/Color+RGB.swift new file mode 100644 index 0000000..b974162 --- /dev/null +++ b/CHDataManagement/Extensions/Color+RGB.swift @@ -0,0 +1,15 @@ +import SwiftUI + +extension Color { + + init(_ r: Int, _ g: Int, _ b: Int) { + self.init(r: r, g: g, b: b) + } + + init(r: Int, g: Int, b: Int) { + self.init( + red: Double(r) / 255, + green: Double(g) / 255, + blue: Double(b) / 255) + } +} diff --git a/CHDataManagement/Extensions/Environment+Language.swift b/CHDataManagement/Extensions/Environment+Language.swift new file mode 100644 index 0000000..2f10990 --- /dev/null +++ b/CHDataManagement/Extensions/Environment+Language.swift @@ -0,0 +1,15 @@ +import Foundation +import SwiftUI + +struct LanguageKey: EnvironmentKey { + + static let defaultValue: ContentLanguage = .english +} + +extension EnvironmentValues { + + var language: ContentLanguage { + get { self[LanguageKey.self] } + set { self[LanguageKey.self] = newValue } + } +} diff --git a/CHDataManagement/Extensions/String+Extensions.swift b/CHDataManagement/Extensions/String+Extensions.swift new file mode 100644 index 0000000..a38c60e --- /dev/null +++ b/CHDataManagement/Extensions/String+Extensions.swift @@ -0,0 +1,11 @@ + +extension String { + + func htmlEscaped() -> String { + replacingOccurrences(of: "&", with: "&") + .replacingOccurrences(of: "\"", with: """) + .replacingOccurrences(of: "'", with: "'") + .replacingOccurrences(of: "<", with: "<") + .replacingOccurrences(of: ">", with: ">") + } +} diff --git a/CHDataManagement/Import/GenericMetadata+Localized.swift b/CHDataManagement/Import/GenericMetadata+Localized.swift new file mode 100644 index 0000000..21d3945 --- /dev/null +++ b/CHDataManagement/Import/GenericMetadata+Localized.swift @@ -0,0 +1,153 @@ +import Foundation + +extension GenericMetadata { + + /** + Metadata localized for a specific language. + */ + struct LocalizedMetadata { + + /** + The language for which the content is specified. + - Note: This field is mandatory + */ + let language: String? + + /** + The title used in the page header. + - Note: This field is mandatory + */ + let title: String? + + /** + The subtitle used in the page header. + */ + let subtitle: String? + + /** + The description text used in the page header + */ + let description: String? + + /** + The title to use for the link preview. + + If `nil` is specified, then the localized element `title` is used. + */ + let linkPreviewTitle: String? + + /** + The file name of the link preview image. + - Note: The image must be located in the element folder. + - Note: If `nil` is specified, then the (localized) thumbnail is used. + */ + let linkPreviewImage: String? + + /** + The description text for the link preview. + - Note: If `nil` is specified, then first the (localized) element `subtitle` is used. + If this is `nil` too, then the localized `description` of the element is used. + */ + let linkPreviewDescription: String? + + /** + The text on the link to show the section page when previewing multiple sections on an overview page. + - Note: If this value is inherited from the parent, if it is not defined. There must be at least one + element in the path that defines this property. + */ + let moreLinkText: String? + + /** + The text on the back navigation link of **contained** elements. + + This text does not appear on the section page, but on the pages contained within the section. + - Note: If this property is not specified, then the root `backLinkText` is used. + - Note: The root element must specify this property. + */ + let backLinkText: String? + + /** + The text to show as a title for placeholder boxes + + Placeholders are included in missing pages. + - Note: If no value is specified, then this property is inherited from the parent. The root element must specify this property. + */ + let placeholderTitle: String? + + /** + The text to show as a description for placeholder boxes + + Placeholders are included in missing pages. + - Note: If no value is specified, then this property is inherited from the parent. The root element must specify this property. + */ + let placeholderText: String? + + /** + An optional suffix to add to the title on a page. + + This can be useful to express a different author, project grouping, etc. + */ + let titleSuffix: String? + + /** + An optional suffix to add to the thumbnail title of a page. + + This can be useful to express a different author, project grouping, etc. + */ + let thumbnailSuffix: String? + + /** + A text to place in the top right corner of a large thumbnail. + + The text should be a very short string to fit into the corner, like `soon`, or `draft` + + - Note: This property is ignored if `thumbnailStyle` is not `large`. + */ + let cornerText: String? + + /** + The external url to use instead of automatically generating the page. + + This property can be used for links to other parts of the site, like additional services. + It can also be set to manually write a page. + */ + let externalUrl: String? + + /** + The text to display for content related to the current page. + + This property is mandatory at root level, and is propagated to child elements. + */ + let relatedContentText: String? + + /** + The text to display on a navigation element pointing to this element as the previous page. + + This property is mandatory at root level, and is propagated to child elements. + */ + let navigationTextAsPreviousPage: String? + + /** + The text to display on the navigation element pointing to this element as the next page. + + This property is mandatory at root level, and is propagated to child elements. + */ + let navigationTextAsNextPage: String? + + /** + The text to display above a slideshow for most recent items. + Only used for elements that define `showMostRecentSection = true` + */ + let mostRecentTitle: String? + + /** + The text to display above a slideshow for featured items. + Only used for elements that define `showFeaturedSection = true` + */ + let featuredTitle: String? + } +} + +extension GenericMetadata.LocalizedMetadata: Codable { + +} diff --git a/CHDataManagement/Import/GenericMetadata.swift b/CHDataManagement/Import/GenericMetadata.swift new file mode 100644 index 0000000..a06b662 --- /dev/null +++ b/CHDataManagement/Import/GenericMetadata.swift @@ -0,0 +1,137 @@ +import Foundation + +/** + The metadata for all site elements. + */ +struct GenericMetadata { + + /** + A custom id to uniquely identify the element on the site. + + The id is used for short-hand links to pages, in the form of `![page](page_id)` + for thumbnail previews or `[text](page:page_id)` for simple links. + + If no custom id is set, then the name of the element folder is used. + */ + let customId: String? + + /** + The author of the content. + + If no author is set, then the author from the parent element is used. + */ + let author: String? + + /** + The (start) date of the element. + + The date is printed on content pages and may also used for sorting elements, + depending on the `useManualSorting` property of the parent. + */ + let date: String? + + /** + The end date of the element. + + This property can be used to specify a date range for a content page. + */ + let endDate: String? + + /** + The deployment state of the page. + + - Note: This property defaults to ``PageState.standard` + */ + let state: String? + + /** + The sort index of the page for manual sorting. + + - Note: This property is only used (and must be set) if `useManualSorting` option of the parent is set. + */ + let sortIndex: Int? + + /** + All files which may occur in content but is stored externally. + + Missing files which would otherwise produce a warning are ignored when included here. + - Note: This property defaults to an empty set. + */ + let externalFiles: Set? + + /** + Specifies additional files which should be copied to the destination when generating the content. + - Note: This property defaults to an empty set. + */ + let requiredFiles: Set? + + /** + Additional images required by the element. + + These images are specified as: `source_name destination_name width (height)`. + */ + let images: Set? + + /** + The path to the thumbnail file. + + This property is optional, and defaults to ``Element.defaultThumbnailName``. + Note: The generator first looks for localized versions of the thumbnail by appending `-[lang]` to the file name, + e.g. `customThumb-en.jpg`. If no file is found, then the specified file is tried. + */ + let thumbnailPath: String? + + /** + The style of thumbnail to use when generating overviews. + + - Note: This property is only relevant for sections. + - Note: This property is inherited from the parent if not specified. + */ + let thumbnailStyle: String? + + /** + Sort the child elements by their `sortIndex` property when generating overviews, instead of using the `date`. + + - Note: This property is only relevant for sections. + - Note: This property defaults to `false` + */ + let useManualSorting: Bool? + + /** + The number of items to show when generating overviews of this element. + - Note: This property is only relevant for sections. + - Note: This property is inherited from the parent if not specified. + */ + let overviewItemCount: Int? + + /** + Indicate the header type to be generated automatically. + + If this option is set to `none`, then custom header code should be present in the page source files + - Note: If not specified, this property defaults to `left`. + - Note: Overview pages are always using `center`. + */ + let headerType: String? + + /** + Indicate that the overview section should contain a `Newest Content` section before the other sections. + - Note: If not specified, this property defaults to `false` + */ + let showMostRecentSection: Bool? + + /** + Indicate that the overview section should contain a `Featured Content` section before the other sections. + The elements are the page ids of the elements contained in the feature. + - Note: If not specified, this property defaults to `false` + */ + let featuredPages: [String]? + + /** + The localized metadata for each language. + */ + let languages: [LocalizedMetadata]? +} + +extension GenericMetadata: Codable { + +} diff --git a/CHDataManagement/Import/ImportableTag.swift b/CHDataManagement/Import/ImportableTag.swift new file mode 100644 index 0000000..2ce5a75 --- /dev/null +++ b/CHDataManagement/Import/ImportableTag.swift @@ -0,0 +1,33 @@ +import Foundation + +struct ImportableTag { + + let languages: [TagLanguage] + + func info(for language: ContentLanguage) -> TagLanguage? { + languages.first { $0.language == language.rawValue } + } +} + +extension ImportableTag: Codable { + +} + +struct TagLanguage { + + let language: String + + let title: String + + let subtitle: String? + + let description: String? + + let moreLinkText: String? + + let backLinkText: String? +} + +extension TagLanguage: Codable { + +} diff --git a/CHDataManagement/Import/Importer.swift b/CHDataManagement/Import/Importer.swift new file mode 100644 index 0000000..73e027f --- /dev/null +++ b/CHDataManagement/Import/Importer.swift @@ -0,0 +1,134 @@ +import Foundation + +struct ImportedContent { + + let posts: [Post] + + let categories: [Tag] +} + +final class Importer { + + var posts: [Post] = [] + + var pages: [Page] = [] + + var tags: [Tag] = [] + + var images: [ImageResource] = [] + + var foldersToSearch: [(path: String, tag: String)] = [ + ("/Users/ch/Downloads/Website/projects/electronics", "electronics"), + ("/Users/ch/Downloads/Website/projects/endeavor", "endeavor"), + ("/Users/ch/Downloads/Website/projects/furniture", "furniture"), + ("/Users/ch/Downloads/Website/projects/lighting", "lighting"), + ("/Users/ch/Downloads/Website/projects/other", "other"), + ("/Users/ch/Downloads/Website/projects/sewing", "sewing"), + ("/Users/ch/Downloads/Website/projects/software", "software"), + ("/Users/ch/Downloads/Website/articles", "articles"), + ("/Users/ch/Downloads/Website/photography", "photography"), + ("/Users/ch/Downloads/Website/travel", "travel") + ] + + func importOldContent() throws { + for (folder, tagName) in foldersToSearch { + let url = URL(filePath: folder) + let tag = try importTag(name: tagName, folder: url) + try importEntries(in: url, tag: tag) + tags.append(tag) + } + posts.sort { $0.startDate > $1.startDate } + //pages.sort { $0.startDate > $1.startDate } + tags.sort() + } + + private func importTag(name: String, folder: URL) throws -> Tag { + let metadataUrl = folder.appending(path: "metadata.json", directoryHint: .notDirectory) + let data = try Data(contentsOf: metadataUrl) + let meta = try JSONDecoder().decode(ImportableTag.self, from: data) + + return .init( + en: meta.info(for: .english)!.title, + de: meta.info(for: .german)!.title) + } + + private func importEntries(in folder: URL, tag: Tag) throws { + try FileManager.default + .contentsOfDirectory(at: folder, includingPropertiesForKeys: [.isDirectoryKey]) + .filter { $0.hasDirectoryPath } + .forEach { try importEntry(at: $0, tag: tag) } + } + + private func importEntry(at url: URL, tag: Tag) throws { + let metadataUrl = url.appending(path: "metadata.json", directoryHint: .notDirectory) + guard FileManager.default.fileExists(atPath: metadataUrl.path()) else { + //print("No entry at \(url.path())") + return + } + let data = try Data(contentsOf: metadataUrl) + let meta = try JSONDecoder().decode(GenericMetadata.self, from: data) + + let page = Page( + id: meta.customId ?? url.lastPathComponent, + isDraft: meta.state == "draft", + metadata: meta.languages!.map(convertPageContent), + externalFiles: meta.externalFiles ?? [], + requiredFiles: meta.requiredFiles ?? [], + images: meta.images ?? []) + pages.append(page) + + let de = meta.languages!.first { $0.language == "de" }! + let en = meta.languages!.first { $0.language == "en" }! + + let thumbnailImageName = meta.thumbnailPath ?? "thumbnail.jpg" + let thumbnailImageUrl = url.appending(path: thumbnailImageName, directoryHint: .notDirectory) + var images: [ImageResource] = [] + if tag.id != "articles" { + if FileManager.default.fileExists(atPath: thumbnailImageUrl.path()) { + let thumbnail = ImageResource( + uniqueId: meta.customId ?? url.lastPathComponent, + altText: .init(en: "An image about \(en.title!)", de: "Ein Bild zu \(de.title!)"), + fileUrl: thumbnailImageUrl) + images.append(thumbnail) + self.images.append(thumbnail) + } else { + print("Thumbnail \(thumbnailImageUrl.path()) not found") + } + } + + let lastPostId = posts.last?.id ?? 0 + + let post = Post( + id: lastPostId + 1, + isDraft: meta.state == "draft" || meta.state == "hidden", + startDate: meta.date!.toDate(), + endDate: meta.endDate?.toDate(), + title: .init(en: en.linkPreviewTitle ?? en.title!, + de: de.linkPreviewTitle ?? de.title!), + text: .init(en: en.linkPreviewDescription ?? en.description ?? "No description", + de: de.linkPreviewDescription ?? de.description ?? "Keine Beschreibung"), + tags: [tag], + images: images) + + posts.append(post) + } + + private func convertPageContent(_ meta: GenericMetadata.LocalizedMetadata) -> LocalizedPage { + .init(language: ContentLanguage(rawValue: meta.language!)!, + urlString: nil, + headline: meta.title!) + } +} + +private extension String { + + private static let metadataDate: DateFormatter = { + let df = DateFormatter() + df.dateFormat = "dd.MM.yy" + return df + }() + + func toDate() -> Date { + String.metadataDate.date(from: self)! + } +} diff --git a/CHDataManagement/Item.swift b/CHDataManagement/Item.swift deleted file mode 100644 index 6c2d726..0000000 --- a/CHDataManagement/Item.swift +++ /dev/null @@ -1,18 +0,0 @@ -// -// Item.swift -// CHDataManagement -// -// Created by CH on 22.06.24. -// - -import Foundation -import SwiftData - -@Model -final class Item { - var timestamp: Date - - init(timestamp: Date) { - self.timestamp = timestamp - } -} diff --git a/CHDataManagement/Model/Content.swift b/CHDataManagement/Model/Content.swift new file mode 100644 index 0000000..122a171 --- /dev/null +++ b/CHDataManagement/Model/Content.swift @@ -0,0 +1,104 @@ +import Foundation + +final class Content: ObservableObject { + + @Published + var posts: [Post] = [] + + @Published + var pages: [Page] = [] + + @Published + var tags: [Tag] = [] + + @Published + var images: [ImageResource] = [] + + @Published + var files: [FileResources] = [] + + func generateFeed(for language: ContentLanguage, bookmarkKey: String) { + let posts = posts.map { $0.feedEntry(for: language) } + DispatchQueue.global(qos: .userInitiated).async { + + let navigationItems: [FeedNavigationLink] = [ + .init(text: .init(en: "Projects", de: "Projekte"), + url: .init(en: "/projects", de: "/projekte")), + .init(text: .init(en: "Adventures", de: "Abenteuer"), + url: .init(en: "/adventures", de: "/abenteuer")), + .init(text: .init(en: "Services", de: "Dienste"), + url: .init(en: "/services", de: "/dienste")), + .init(text: .init(en: "Tags", de: "Kategorien"), + url: .init(en: "/tags", de: "/kategorien")), + ] + + let feed = Feed( + language: language, + title: .init(en: "Blog | CH", de: "Blog | CH"), + description: .init(en: "The latests posts, projects and adventures", + de: "Die neusten Beiträge, Projekte und Abenteuer"), + iconDescription: .init(en: "An icon consisting of the letters C and H in blue and orange", + de: "Ein Logo aus den Buchstaben C und H in Blau und Orange"), + navigationItems: navigationItems, + posts: posts) + let fileContent = feed.content + Content.accessFolderFromBookmark(key: bookmarkKey) { folder in + let outputFile = folder.appendingPathComponent("feed.html", isDirectory: false) + do { + try fileContent + .data(using: .utf8)! + .write(to: outputFile) + } catch { + print("Failed to save: \(error)") + } + } + } + } + + func importOldContent() { + let importer = Importer() + do { + try importer.importOldContent() + } catch { + print(error) + return + } + self.posts = importer.posts + self.tags = importer.tags + #warning("TODO: Copy page sources to data folder") + self.pages = importer.pages + self.images = importer.images + #warning("TODO: Copy images to data folder") + } + + static func accessFolderFromBookmark(key: String, operation: (URL) -> Void) { + guard let bookmarkData = UserDefaults.standard.data(forKey: key) else { + print("No bookmark data to access folder") + return + } + var isStale = false + let folderURL: URL + do { + // Resolve the bookmark to get the folder URL + folderURL = try URL(resolvingBookmarkData: bookmarkData, options: .withSecurityScope, relativeTo: nil, bookmarkDataIsStale: &isStale) + } catch { + print("Failed to resolve bookmark: \(error)") + return + } + + if isStale { + print("Bookmark is stale, consider saving a new bookmark.") + } + + // Start accessing the security-scoped resource + if folderURL.startAccessingSecurityScopedResource() { + print("Accessing folder: \(folderURL.path)") + + operation(folderURL) + folderURL.stopAccessingSecurityScopedResource() + } else { + print("Failed to access folder: \(folderURL.path)") + } + } + +} diff --git a/CHDataManagement/Model/ContentLanguage.swift b/CHDataManagement/Model/ContentLanguage.swift new file mode 100644 index 0000000..168e9e9 --- /dev/null +++ b/CHDataManagement/Model/ContentLanguage.swift @@ -0,0 +1,8 @@ +import Foundation + +enum ContentLanguage: String { + + case english = "en" + + case german = "de" +} diff --git a/CHDataManagement/Model/FileResource.swift b/CHDataManagement/Model/FileResource.swift new file mode 100644 index 0000000..fc5ca6f --- /dev/null +++ b/CHDataManagement/Model/FileResource.swift @@ -0,0 +1,16 @@ +import Foundation + +final class FileResources: ObservableObject { + + /// Globally unique id + @Published + var uniqueId: String + + @Published + var description: String + + init(uniqueId: String, description: String) { + self.uniqueId = uniqueId + self.description = description + } +} diff --git a/CHDataManagement/Model/ImageResource.swift b/CHDataManagement/Model/ImageResource.swift new file mode 100644 index 0000000..a0a5105 --- /dev/null +++ b/CHDataManagement/Model/ImageResource.swift @@ -0,0 +1,91 @@ +import SwiftUI + +final class ImageResource: ObservableObject { + + /// Globally unique id + @Published + var id: String + + @Published + var altText: LocalizedText + + @Published + var size: CGSize = .zero + + var aspectRatio: CGFloat { + guard size.height > 0 else { + return 0 + } + return size.width / size.height + } + + private let source: ImageSource + + init(uniqueId: String, altText: LocalizedText, fileUrl: URL) { + self.id = uniqueId + self.source = .file(fileUrl) + self.altText = altText + } + + init(resourceName: String) { + self.id = resourceName + self.source = .resource(resourceName) + self.altText = .init(en: "A test image included in the bundle", de: "Ein Test-Image aus dem Bundle") + } + + private enum ImageSource { + case file(URL) + case resource(String) + } +} + +extension ImageResource: Identifiable { + +} + +extension ImageResource: Equatable { + + static func == (lhs: ImageResource, rhs: ImageResource) -> Bool { + lhs.id == rhs.id + } +} + +extension ImageResource { + + var imageToDisplay: Image { + switch source { + case .file(let url): + return image(at: url) + case .resource(let name): + return .init(name) + } + } + + private func image(at url: URL) -> Image { + let imageData: Data + do { + imageData = try Data(contentsOf: url) + } catch { + print("Failed to load image data from \(url.path): \(error)") + return failureImage + } + guard let loadedImage = NSImage(data: imageData) else { + print("Failed to create image from \(url.path)") + return failureImage + } + self.size = loadedImage.size + return .init(nsImage: loadedImage) + } + + private var failureImage: SwiftUI.Image { + Image(systemSymbol: .exclamationmarkTriangle) + } + +} + +extension ImageResource { + + func feedEntryImage(for language: ContentLanguage) -> FeedEntryData.Image { + .init(mainImageUrl: "images/\(id)", altText: altText.getText(for: language)) + } +} diff --git a/CHDataManagement/Model/LocalizedText.swift b/CHDataManagement/Model/LocalizedText.swift new file mode 100644 index 0000000..d2863df --- /dev/null +++ b/CHDataManagement/Model/LocalizedText.swift @@ -0,0 +1,48 @@ +import Foundation +import SwiftUI + +/// A simple container for localized text + +final class LocalizedText: ObservableObject { + + @Published + var en: String + + @Published + var de: String + + init(en: String, de: String) { + self.en = en + self.de = de + } + + var id: String { + en + } + + func set(text: String, for language: ContentLanguage) { + switch language { + case .english: self.en = text + case .german: self.de = text + } + } + + func getText(for language: ContentLanguage) -> String { + switch language { + case .english: return en + case .german: return de + } + } + + @MainActor + func text(for language: ContentLanguage) -> Binding { + Binding( + get: { + self.getText(for: language) + }, + set: { newValue in + self.set(text: newValue, for: language) + } + ) + } +} diff --git a/CHDataManagement/Model/Page.swift b/CHDataManagement/Model/Page.swift new file mode 100644 index 0000000..861c530 --- /dev/null +++ b/CHDataManagement/Model/Page.swift @@ -0,0 +1,89 @@ +import Foundation + +final class Page: ObservableObject { + + /** + The unique id of the entry + */ + @Published + var id: String + + @Published + var isDraft: Bool + + @Published + var metadata: [LocalizedPage] + + /** + All files which may occur in content but is stored externally. + + Missing files which would otherwise produce a warning are ignored when included here. + - Note: This property defaults to an empty set. + */ + @Published + var externalFiles: Set = [] + + /** + Specifies additional files which should be copied to the destination when generating the content. + - Note: This property defaults to an empty set. + */ + @Published + var requiredFiles: Set = [] + + /** + Additional images required by the element. + + These images are specified as: `source_name destination_name width (height)`. + */ + @Published + var images: Set = [] + + init(id: String, isDraft: Bool, metadata: [LocalizedPage], externalFiles: Set = [], requiredFiles: Set = [], images: Set = []) { + self.id = id + self.isDraft = isDraft + self.metadata = metadata + self.externalFiles = externalFiles + self.requiredFiles = requiredFiles + self.images = images + } + + func metadata(for language: ContentLanguage) -> LocalizedPage? { + metadata.first { $0.language == language } + } + +} + +struct LocalizedPage { + + let language: ContentLanguage + + /** + The string to use when creating the url for the page. + + Defaults to ``id`` if unset. + */ + var urlString: String? + + /** + The headline to use when showing the entry on it's own page + */ + var headline: String +} + +extension Page: Identifiable { + +} + +extension Page: Equatable { + + static func == (lhs: Page, rhs: Page) -> Bool { + lhs.id == rhs.id + } +} + +extension Page: Hashable { + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } +} diff --git a/CHDataManagement/Model/Post.swift b/CHDataManagement/Model/Post.swift new file mode 100644 index 0000000..d701748 --- /dev/null +++ b/CHDataManagement/Model/Post.swift @@ -0,0 +1,170 @@ +import SwiftUI + +final class Post: ObservableObject { + + let id: Int + + @Published + var isDraft: Bool + + @Published + var startDate: Date + + @Published + var hasEndDate: Bool + + @Published + var endDate: Date + + @Published + var tags: [Tag] + + let title: LocalizedText + + let text: LocalizedText + + var images: [ImageResource] + + /// The page linked to by this post + @Published + var linkedPage: Page? + + init(id: Int, + isDraft: Bool = false, + startDate: Date, + endDate: Date? = nil, + title: LocalizedText, + text: LocalizedText, + tags: [Tag], + images: [ImageResource]) { + self.id = id + self.isDraft = isDraft + self.startDate = startDate + self.hasEndDate = endDate != nil + self.endDate = endDate ?? startDate + self.title = title + self.text = text + self.tags = tags + self.images = images + } +} + +extension Post: Identifiable { + +} + +extension Post: Equatable { + + static func == (lhs: Post, rhs: Post) -> Bool { + lhs.id == rhs.id + } +} + +extension Post: Hashable { + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } +} + + +// MARK: Feed entry + +extension Post { + + private static let englishDate: DateFormatter = { + let df = DateFormatter() + df.locale = .init(identifier: "en") + df.dateFormat = "d. MMMM yyyy" + return df + }() + + private static let germanDate: DateFormatter = { + let df = DateFormatter() + df.locale = .init(identifier: "de") + df.dateFormat = "d. MMMM yyyy" + return df + }() + + private static let englishDayAndMonth: DateFormatter = { + let df = DateFormatter() + df.locale = .init(identifier: "en") + df.dateFormat = "d. MMMM" + return df + }() + + private static let germanDayAndMonth: DateFormatter = { + let df = DateFormatter() + df.locale = .init(identifier: "de") + df.dateFormat = "d. MMMM" + return df + }() + + private static let day: DateFormatter = { + let df = DateFormatter() + df.dateFormat = "d." + return df + }() + + private static func dayAndMonth(of date: Date, in language: ContentLanguage) -> String { + switch language { + case .english: return englishDayAndMonth.string(from: date) + case .german: return germanDayAndMonth.string(from: date) + } + } + + private static func dateString(for date: Date, in language: ContentLanguage) -> String { + switch language { + case .english: return englishDate.string(from: date) + case .german: return germanDate.string(from: date) + } + } + + private func datePrefixString(in language: ContentLanguage) -> String { + guard Calendar.current.isDate(startDate, equalTo: endDate, toGranularity: .year) else { + // Different year, return full string + return startDate.formatted(date: .long, time: .omitted) + } + guard Calendar.current.isDate(startDate, equalTo: endDate, toGranularity: .month) else { + // Different month + return Post.dayAndMonth(of: startDate, in: language) + } + + return Post.day.string(from: startDate) + } + + func dateText(in language: ContentLanguage) -> String { + guard hasEndDate else { + return Post.dateString(for: startDate, in: language) + } + let endText = Post.dateString(for: endDate, in: language) + return "\(datePrefixString(in: language)) - \(endText)" + } + + private func paragraphs(in language: ContentLanguage) -> [String] { + text + .getText(for: language) + .components(separatedBy: "\n") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { $0 != "" } + } + + func linkToPageInFeed(for language: ContentLanguage) -> FeedEntryData.Link? { + nil //.init(url: <#T##String#>, text: <#T##String#>) + } + + func feedEntry(for language: ContentLanguage) -> FeedEntryData { + .init( + entryId: "\(id)", + title: title.getText(for: language), + textAboveTitle: dateText(in: language), + link: linkToPageInFeed(for: language), + tags: tags.map { $0.data(in: language) }, + text: paragraphs(in: language), + images: images.map { $0.feedEntryImage(for: language) }) + } + + var displayImages: [Image] { + images.map { $0.imageToDisplay } + } +} diff --git a/CHDataManagement/Model/Tag.swift b/CHDataManagement/Model/Tag.swift new file mode 100644 index 0000000..eb21be4 --- /dev/null +++ b/CHDataManagement/Model/Tag.swift @@ -0,0 +1,69 @@ +import Foundation + +final class Tag: ObservableObject { + + var id: String { + name.getText(for: .english).lowercased().replacingOccurrences(of: " ", with: "-") + } + + @Published + var name: LocalizedText + + init(en: String, de: String) { + self.name = .init(en: en, de: de) + } + + var linkName: String { + id.lowercased().replacingOccurrences(of: " ", with: "-") + } + + var url: String { + "/tags/\(linkName).html" + } +} + +extension Tag { + + func getUrl(for language: ContentLanguage) -> String { + "/\(language.rawValue)/tags/\(id).html" + } + + func data(in language: ContentLanguage) -> FeedEntryData.Tag { + .init( + name: name.getText(for: language), + url: getUrl(for: language) + ) + } +} + +extension Tag: ExpressibleByStringLiteral { + + convenience init(stringLiteral value: StringLiteralType) { + self.init(en: value.capitalized, de: value.capitalized) + } +} + +extension Tag: Identifiable { + +} + +extension Tag: Equatable { + + static func == (_ lhs: Tag, _ rhs: Tag) -> Bool { + lhs.id == rhs.id + } +} + +extension Tag: Hashable { + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } +} + +extension Tag: Comparable { + + static func < (lhs: Tag, rhs: Tag) -> Bool { + lhs.id < rhs.id + } +} diff --git a/CHDataManagement/Page Elements/FeedEntry.swift b/CHDataManagement/Page Elements/FeedEntry.swift new file mode 100644 index 0000000..914d567 --- /dev/null +++ b/CHDataManagement/Page Elements/FeedEntry.swift @@ -0,0 +1,41 @@ +import Foundation + +struct FeedEntry { + + let data: FeedEntryData + + init(data: FeedEntryData) { + self.data = data + } + + func addContent(to result: inout String) { + #warning("TODO: Select CSS classes based on existence of link (hover effects, mouse pointer") + result += "
" + ImageGallery(id: data.entryId, images: data.images) + .addContent(to: &result) + + if let url = data.link?.url { + result += "
" + } else { + result += "
" + } + result += "

\(data.textAboveTitle)

" + if let title = data.title { + result += "

\(title.htmlEscaped())

" + } + if !data.tags.isEmpty { + result += "
" + for tag in data.tags { + result += "\(tag.name)" + } + result += "
" + } + for paragraph in data.text { + result += "

\(paragraph)

" + } + if let url = data.link { + result += "" + } + result += "
" // Closes card-content and card + } +} diff --git a/CHDataManagement/Page Elements/FeedEntryContent.swift b/CHDataManagement/Page Elements/FeedEntryContent.swift new file mode 100644 index 0000000..c6e4eed --- /dev/null +++ b/CHDataManagement/Page Elements/FeedEntryContent.swift @@ -0,0 +1,44 @@ +//import Elementary + +struct FeedEntryContent { + + let url: String? + + let inner: Content + + init(url: String?, inner: Content) { + self.url = url + self.inner = inner + } + + func addContent(to result: inout String, inner: () -> Void) -> Void { + if let url { + result += "
" + } else { + result += "
" + } + inner() + result += "
" + } +} + +/* +extension FeedEntryContent: HTML where Content: HTML { + + init(url: String?, @HTMLBuilder content: () -> Content) { + self.init(url: url, inner: content()) + } + + var content: some HTML { + if let url { + div(.class("card-content"), .on(.click, "window.location.href='\(url)'")) { + inner + } + } else { + div(.class("card-content")) { + inner + } + } + } +} +*/ diff --git a/CHDataManagement/Page Elements/FeedEntryData.swift b/CHDataManagement/Page Elements/FeedEntryData.swift new file mode 100644 index 0000000..1d97dfe --- /dev/null +++ b/CHDataManagement/Page Elements/FeedEntryData.swift @@ -0,0 +1,51 @@ + +struct FeedEntryData { + + let entryId: String + + let title: String? + + let textAboveTitle: String + + let link: Link? + + let tags: [Tag] + + let text: [String] + + let images: [Image] + + init(entryId: String, title: String?, textAboveTitle: String, link: Link?, tags: [Tag], text: [String], images: [Image]) { + self.entryId = entryId + self.title = title + self.textAboveTitle = textAboveTitle + self.link = link + self.tags = tags + self.text = text + self.images = images + } + + struct Link { + + let url: String + + let text: String + + } + + struct Tag { + + let name: String + + let url: String + + } + + struct Image { + + let mainImageUrl: String + + let altText: String + + } +} diff --git a/CHDataManagement/Page Elements/ImageGallery.swift b/CHDataManagement/Page Elements/ImageGallery.swift new file mode 100644 index 0000000..5e4405e --- /dev/null +++ b/CHDataManagement/Page Elements/ImageGallery.swift @@ -0,0 +1,83 @@ +import Foundation + +struct ImageGallery { + + let id: String + + let images: [FeedEntryData.Image] + + init(id: String, images: [FeedEntryData.Image]) { + self.id = id + self.images = images + } + + func addContent(to result: inout String) { + guard !images.isEmpty else { + return + } + + result += "
" + + guard images.count > 1 else { + let image = images[0] + result += "
\"\(image.altText.htmlEscaped())\"
" + result += "
" // Close swiper, swiper-wrapper + return + } + + for image in images { + // TODO: Use different images based on device + result += "
" + result += "\"\(image.altText.htmlEscaped())\"" + result += "
" + result += "
" // Close swiper-slide + } + + result += "
" + result += "
" + result += "
" + result += "
" // Close swiper, swiper-wrapper + } + + static func swiperInit(id: String) -> String { + """ + var swiper\(id) = new Swiper("#\(id)", { + loop: true, + slidesPerView: 1, + spaceBetween: 30, + centeredSlides: true, + keyboard: { enabled: true }, + pagination: { + el: "#\(id) .swiper-pagination", + clickable: true + }, + navigation: { + nextEl: "#\(id) .swiper-button-next", + prevEl: "#\(id) .swiper-button-prev" + } + }); + """ + } +} + +/* +extension ImageGallery: HTML { + + var content: some HTML { + div(.id(id), .class("swiper")) { + div(.class("swiper-wrapper")) { + for image in images { + div(.class("swiper-slide")) { + // TODO: Use different images based on device + img(.src(image.mainImageUrl), .lazyLoad) + div(.class("swiper-lazy-preloader"), .class("swiper-lazy-preloader-white")) { } + } + } + } + div(.class("swiper-button-next")) { } + div(.class("swiper-button-prev")) { } + div(.class("swiper-pagination")) { } + } + } +} +*/ diff --git a/CHDataManagement/Page Elements/PageHead.swift b/CHDataManagement/Page Elements/PageHead.swift new file mode 100644 index 0000000..f9e1a47 --- /dev/null +++ b/CHDataManagement/Page Elements/PageHead.swift @@ -0,0 +1,36 @@ +import Foundation +//import Elementary + +struct PageHead { + + let title: String + + let description: String + + var content: String { + """ + + + \(title) + + + + + + """ + } +} + +/* +extension PageHead: HTML { + + var content: some HTML { + meta(.charset(.utf8)) + meta(.title(title)) + meta(.name(.viewport), .content("width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1")) + meta(.name(.description), .content(description)) + link(.rel(.stylesheet), .href("style.css")) + link(.rel(.stylesheet), .href("swiper.css")) + } +} +*/ diff --git a/CHDataManagement/Pages/Feed.swift b/CHDataManagement/Pages/Feed.swift new file mode 100644 index 0000000..e060682 --- /dev/null +++ b/CHDataManagement/Pages/Feed.swift @@ -0,0 +1,79 @@ +import Foundation + +struct FeedNavigationLink { + + let text: LocalizedText + + let url: LocalizedText +} + +struct Feed { + + private let navigationIconPath = "/assets/icons/ch.svg" + + let language: ContentLanguage + + let title: LocalizedText + + let description: LocalizedText + + let iconDescription: LocalizedText + + let navigationItems: [FeedNavigationLink] + + let posts: [FeedEntryData] + + var content: String { + #warning("TODO: Split feed into multiple pages") + var result = "" + result += "" + let head = PageHead( + title: title.getText(for: language), + description: description.getText(for: language)) + result += head.content + result += "" + addNavbar(to: &result) + result += "
" + for post in posts { + FeedEntry(data: post) + .addContent(to: &result) + } + + addSwiperInits(to: &result) + result += "
" // Close content + return result + } + + #warning("TODO: Set correct navigation links and texts") + private func addNavbar(to result: inout String) { + result += "" // Close nav-center, navbar + } + + private func addSwiperInits(to result: inout String) { + if posts.contains(where: { $0.images.count > 1 }) { + result += "" + } + } +} diff --git a/CHDataManagement/Preview Content/MockImage.swift b/CHDataManagement/Preview Content/MockImage.swift new file mode 100644 index 0000000..2c243a8 --- /dev/null +++ b/CHDataManagement/Preview Content/MockImage.swift @@ -0,0 +1,19 @@ +import SwiftUI + +/// An image loaded from the app resources for test purposes +struct MockImage { + + let name: String + + static var images: [ImageResource] { + ["image1", "image2", "image3", "image4"] + .map(ImageResource.init) + } +} + +extension MockImage: ExpressibleByStringLiteral { + + init(stringLiteral value: StringLiteralType) { + self.name = value + } +} diff --git a/CHDataManagement/Preview Content/Page+Mock.swift b/CHDataManagement/Preview Content/Page+Mock.swift new file mode 100644 index 0000000..09e0cc9 --- /dev/null +++ b/CHDataManagement/Preview Content/Page+Mock.swift @@ -0,0 +1,18 @@ +import Foundation + +extension Page { + + static var empty: Page { + .init( + id: "my-id", + isDraft: true, + metadata: [ + .init(language: .english, headline: "Title"), + .init(language: .german, headline: "Titel") + ], + externalFiles: [], + requiredFiles: [], + images: []) + } +} + diff --git a/CHDataManagement/Preview Content/Post+Mock.swift b/CHDataManagement/Preview Content/Post+Mock.swift new file mode 100644 index 0000000..1db9da3 --- /dev/null +++ b/CHDataManagement/Preview Content/Post+Mock.swift @@ -0,0 +1,53 @@ + +extension Post { + + static var empty: Post { + .init(id: 0, + isDraft: true, + startDate: .now, + title: .init(en: "The title", de: "Der Titel"), + text: .init(en: "", de: ""), + tags: [], + images: []) + } + + static var mock: Post { + Post( + id: 1, + isDraft: false, + startDate: .now, + endDate: nil, + title: .init(en: "The title", de: "Der Titel"), + text: .init( + en: "Very nice and solitary hike from Oberau to Krottenkopf. Challenging and rewarding, due to the length and height.", + de: "Sehr schöne und einsame Tour von Oberau zum Krottenkopf. Abwechslungsreich und stellenweise fordernd, aufgrund der Länge und der Höhenmeter auch anstrengend." + ), + tags: [ + Tag(en: "Nature", de: "Natur"), + Tag(en: "Sports", de: "Sport"), + Tag(en: "Hiking", de: "Wandern") + ], + images: [] + ) + } + + static var fullMock: Post { + .init( + id: 2, + isDraft: true, + startDate: .now.addingTimeInterval(-86400), endDate: .now, + title: .init(en: "A longer title", de: "Ein langer Titel"), + text: .init( + en: "Very nice and solitary hike from Oberau to Krottenkopf. Challenging and rewarding, due to the length and height.", + de: "Sehr schöne und einsame Tour von Oberau zum Krottenkopf. Abwechslungsreich und stellenweise fordernd, aufgrund der Länge und der Höhenmeter auch anstrengend." + ), + tags: [ + Tag(en: "Nature", de: "Natur"), + Tag(en: "Sports", de: "Sport"), + Tag(en: "Hiking", de: "Wandern"), + Tag(en: "Mountains", de: "Berge") + ], + images: MockImage.images + ) + } +} diff --git a/CHDataManagement/Preview Content/Preview Assets.xcassets/image1.imageset/Contents.json b/CHDataManagement/Preview Content/Preview Assets.xcassets/image1.imageset/Contents.json new file mode 100644 index 0000000..815b657 --- /dev/null +++ b/CHDataManagement/Preview Content/Preview Assets.xcassets/image1.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "image1.jpg", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/CHDataManagement/Preview Content/Preview Assets.xcassets/image1.imageset/image1.jpg b/CHDataManagement/Preview Content/Preview Assets.xcassets/image1.imageset/image1.jpg new file mode 100644 index 0000000..9f9d233 Binary files /dev/null and b/CHDataManagement/Preview Content/Preview Assets.xcassets/image1.imageset/image1.jpg differ diff --git a/CHDataManagement/Preview Content/Preview Assets.xcassets/image2.imageset/Contents.json b/CHDataManagement/Preview Content/Preview Assets.xcassets/image2.imageset/Contents.json new file mode 100644 index 0000000..15e7d20 --- /dev/null +++ b/CHDataManagement/Preview Content/Preview Assets.xcassets/image2.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "image2.jpg", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/CHDataManagement/Preview Content/Preview Assets.xcassets/image2.imageset/image2.jpg b/CHDataManagement/Preview Content/Preview Assets.xcassets/image2.imageset/image2.jpg new file mode 100644 index 0000000..70c6ec8 Binary files /dev/null and b/CHDataManagement/Preview Content/Preview Assets.xcassets/image2.imageset/image2.jpg differ diff --git a/CHDataManagement/Preview Content/Preview Assets.xcassets/image3.imageset/Contents.json b/CHDataManagement/Preview Content/Preview Assets.xcassets/image3.imageset/Contents.json new file mode 100644 index 0000000..86c4dd6 --- /dev/null +++ b/CHDataManagement/Preview Content/Preview Assets.xcassets/image3.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "image3.jpg", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/CHDataManagement/Preview Content/Preview Assets.xcassets/image3.imageset/image3.jpg b/CHDataManagement/Preview Content/Preview Assets.xcassets/image3.imageset/image3.jpg new file mode 100644 index 0000000..ffe4b9f Binary files /dev/null and b/CHDataManagement/Preview Content/Preview Assets.xcassets/image3.imageset/image3.jpg differ diff --git a/CHDataManagement/Preview Content/Preview Assets.xcassets/image4.imageset/Contents.json b/CHDataManagement/Preview Content/Preview Assets.xcassets/image4.imageset/Contents.json new file mode 100644 index 0000000..1025dfb --- /dev/null +++ b/CHDataManagement/Preview Content/Preview Assets.xcassets/image4.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "image4.jpg", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/CHDataManagement/Preview Content/Preview Assets.xcassets/image4.imageset/image4.jpg b/CHDataManagement/Preview Content/Preview Assets.xcassets/image4.imageset/image4.jpg new file mode 100644 index 0000000..3fedd33 Binary files /dev/null and b/CHDataManagement/Preview Content/Preview Assets.xcassets/image4.imageset/image4.jpg differ diff --git a/CHDataManagement/Views/Files/FilesView.swift b/CHDataManagement/Views/Files/FilesView.swift new file mode 100644 index 0000000..d2afa66 --- /dev/null +++ b/CHDataManagement/Views/Files/FilesView.swift @@ -0,0 +1,20 @@ +import SwiftUI + +struct FilesView: View { + + @EnvironmentObject + var content: Content + + var body: some View { + ScrollView { + VStack { + + } + } + Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/) + } +} + +#Preview { + FilesView() +} diff --git a/CHDataManagement/Views/Generic/FlowHStack.swift b/CHDataManagement/Views/Generic/FlowHStack.swift new file mode 100644 index 0000000..9da99b1 --- /dev/null +++ b/CHDataManagement/Views/Generic/FlowHStack.swift @@ -0,0 +1,53 @@ +import SwiftUI + +struct FlowHStack: Layout { + + var horizontalSpacing: CGFloat = 8 + + var verticalSpacing: CGFloat = 8 + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + let subviewSizes = subviews.map { $0.sizeThatFits(proposal) } + let maxSubviewHeight = subviewSizes.map { $0.height }.max() ?? .zero + var currentRowWidth: CGFloat = .zero + var totalHeight: CGFloat = maxSubviewHeight + var totalWidth: CGFloat = .zero + + for size in subviewSizes { + let requestedRowWidth = currentRowWidth + horizontalSpacing + size.width + let availableRowWidth = proposal.width ?? .zero + let willOverflow = requestedRowWidth > availableRowWidth + + if willOverflow { + totalHeight += verticalSpacing + maxSubviewHeight + currentRowWidth = size.width + } else { + currentRowWidth = requestedRowWidth + } + + totalWidth = max(totalWidth, currentRowWidth) + } + + return CGSize(width: totalWidth, height: totalHeight) + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { + let subviewSizes = subviews.map { $0.sizeThatFits(proposal) } + let maxSubviewHeight = subviewSizes.map { $0.height }.max() ?? .zero + var point = CGPoint(x: bounds.minX, y: bounds.minY) + + for index in subviews.indices { + let requestedWidth = point.x + subviewSizes[index].width + let availableWidth = bounds.maxX + let willOverflow = requestedWidth > availableWidth + + if willOverflow { + point.x = bounds.minX + point.y += maxSubviewHeight + verticalSpacing + } + + subviews[index].place(at: point, proposal: ProposedViewSize(subviewSizes[index])) + point.x += subviewSizes[index].width + horizontalSpacing + } + } +} diff --git a/CHDataManagement/Views/Generic/HorizontalCenter.swift b/CHDataManagement/Views/Generic/HorizontalCenter.swift new file mode 100644 index 0000000..52bc257 --- /dev/null +++ b/CHDataManagement/Views/Generic/HorizontalCenter.swift @@ -0,0 +1,33 @@ +import SwiftUI + +/** + A view that centers the content horizontally using an `HStack` + */ +struct HorizontalCenter : View where Content : View { + + let alignment: VerticalAlignment + + let spacing: CGFloat? + + let content: Content + + public init(alignment: VerticalAlignment = .center, spacing: CGFloat? = nil, @ViewBuilder content: () -> Content) { + self.alignment = alignment + self.spacing = spacing + self.content = content() + } + + var body: some View { + HStack(alignment: alignment, spacing: spacing) { + Spacer() + content + Spacer() + } + } +} + +#Preview { + HorizontalCenter { + Text("Test") + } +} diff --git a/CHDataManagement/Views/Images/FlexibleColumnView.swift b/CHDataManagement/Views/Images/FlexibleColumnView.swift new file mode 100644 index 0000000..6560f87 --- /dev/null +++ b/CHDataManagement/Views/Images/FlexibleColumnView.swift @@ -0,0 +1,49 @@ +import SwiftUI + +struct FlexibleColumnView: View where Content: Identifiable, Inner: View { + + @Binding + var items: [Content] + + let maximumItemWidth: CGFloat + + let spacing: CGFloat + + private let content: (_ item: Content, _ width: CGFloat) -> Inner + + init(items: Binding<[Content]>, maximumItemWidth: CGFloat = 300, spacing: CGFloat = 20, content: @escaping (_ item: Content, _ width: CGFloat) -> Inner) { + self._items = items + self.maximumItemWidth = maximumItemWidth + self.spacing = spacing + self.content = content + } + + var body: some View { + GeometryReader { geometry in + let totalWidth = geometry.size.width + let columnCount = max(Int((totalWidth + spacing) / (maximumItemWidth + spacing)), 1) + let totalSpacing = spacing * CGFloat(columnCount + 1) + let trueItemWidth = (totalWidth - totalSpacing) / CGFloat(columnCount) + + let columns = Array(repeating: GridItem(.flexible(), spacing: spacing), count: columnCount) + + ScrollView { + LazyVGrid(columns: columns, spacing: spacing) { + ForEach(items) { item in + content(item, trueItemWidth) + } + } + .padding(spacing) + } + } + } +} + +#Preview { + FlexibleColumnView(items: .constant(MockImage.images), maximumItemWidth: 150) { image, width in + image.imageToDisplay + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: width) + } +} diff --git a/CHDataManagement/Views/Images/ImageDetailsView.swift b/CHDataManagement/Views/Images/ImageDetailsView.swift new file mode 100644 index 0000000..9cf553c --- /dev/null +++ b/CHDataManagement/Views/Images/ImageDetailsView.swift @@ -0,0 +1,59 @@ +import SwiftUI + +struct ImageDetailsView: View { + + @Environment(\.language) + var language + + let image: ImageResource + + @State + private var newId: String + + init(image: ImageResource) { + self.image = image + self.newId = image.id + } + + var body: some View { + VStack(alignment: .leading) { + Text("Unique identifier") + .font(.headline) + HStack { + TextField("", text: $newId) + Button(action: setNewId) { + Text("Update") + } + } + Text("Description") + .font(.headline) + TextField("", text: image.altText.text(for: language)) + Text("Info") + .font(.headline) + HStack(alignment: .top) { + VStack(alignment: .leading) { + Text("Original Size") + Text("Aspect ratio") + } + VStack(alignment: .trailing) { + Text("\(Int(image.size.width)) x \(Int(image.size.height))") + Text("\(image.aspectRatio)") + } + }.padding(.vertical) + Text("Versions") + .font(.headline) + Spacer() + } + .padding() + } + + private func setNewId() { + #warning("Check if ID is unique") + // TODO: Clean id + image.id = newId + } +} + +#Preview { + ImageDetailsView(image: MockImage.images.first!) +} diff --git a/CHDataManagement/Views/Images/ImagesView.swift b/CHDataManagement/Views/Images/ImagesView.swift new file mode 100644 index 0000000..d740f15 --- /dev/null +++ b/CHDataManagement/Views/Images/ImagesView.swift @@ -0,0 +1,62 @@ +import SwiftUI +import SFSafeSymbols + +struct ImagesView: View { + + @EnvironmentObject + var content: Content + + let maximumItemWidth: CGFloat = 300 + + let aspectRatio: CGFloat = 1.5 + + let spacing: CGFloat = 20 + + @State + private var selectedImage: ImageResource? + + @State + private var showImageDetails = false + + var body: some View { + FlexibleColumnView(items: $content.images) { image, width in + let isSelected = selectedImage == image + let borderColor: Color = isSelected ? .accentColor : .clear + return image.imageToDisplay + .resizable() + .aspectRatio(contentMode: .fit) + .border(borderColor, width: 5) + .frame(width: width) + .onTapGesture { didTap(image: image) } + } + .inspector(isPresented: $showImageDetails) { + if let selectedImage { + ImageDetailsView(image: selectedImage) + } else { + Text("Select an image to show its details") + } + } + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button(action: { showImageDetails.toggle() }) { + Label("Details", systemSymbol: .infoCircle) + } + } + } + } + + private func didTap(image: ImageResource) { + if selectedImage == image { + selectedImage = nil + } else { + selectedImage = image + } + } +} + +#Preview { + let content = Content() + content.images = MockImage.images + return ImagesView() + .environmentObject(content) +} diff --git a/CHDataManagement/Views/Pages/PageDetailView.swift b/CHDataManagement/Views/Pages/PageDetailView.swift new file mode 100644 index 0000000..0fce300 --- /dev/null +++ b/CHDataManagement/Views/Pages/PageDetailView.swift @@ -0,0 +1,17 @@ +import SwiftUI + +struct PageDetailView: View { + + @ObservedObject var page: Page + + @Binding + var language: ContentLanguage + + var body: some View { + Text(page.metadata(for: language)?.headline ?? "No headline") + } +} + +#Preview { + PageDetailView(page: .empty, language: .constant(.english)) +} diff --git a/CHDataManagement/Views/Posts/DatePickerView.swift b/CHDataManagement/Views/Posts/DatePickerView.swift new file mode 100644 index 0000000..5efe5d6 --- /dev/null +++ b/CHDataManagement/Views/Posts/DatePickerView.swift @@ -0,0 +1,49 @@ +import SwiftUI + +struct DatePickerView: View { + + @ObservedObject + var post: Post + + @Binding var showDatePicker: Bool + + var body: some View { + NavigationView { + VStack { + HStack(alignment: .top) { + VStack { + Text("Start date") + .font(.headline) + .padding(.vertical, 3) + DatePicker("", selection: $post.startDate, displayedComponents: .date) + .datePickerStyle(GraphicalDatePickerStyle()) + .labelsHidden() + .padding() + + } + + VStack { + Toggle("End date", isOn: $post.hasEndDate) + .toggleStyle(.switch) + .font(.headline) + DatePicker("Select a date", selection: $post.startDate, displayedComponents: .date) + .datePickerStyle(GraphicalDatePickerStyle()) + .labelsHidden() + .padding() + .disabled(!post.hasEndDate) + } + } + Button("Done") { + showDatePicker = false + } + Spacer() + } + .navigationTitle("Pick a Date") + .padding() + } + } +} + +#Preview { + DatePickerView(post: .mock, showDatePicker: .constant(true)) +} diff --git a/CHDataManagement/Views/Posts/PostImageGalleryView.swift b/CHDataManagement/Views/Posts/PostImageGalleryView.swift new file mode 100644 index 0000000..6eb6b2c --- /dev/null +++ b/CHDataManagement/Views/Posts/PostImageGalleryView.swift @@ -0,0 +1,83 @@ +import SwiftUI +import SFSafeSymbols + +private struct NavigationIcon: View { + + let symbol: SFSymbol + + let edge: Edge.Set + + var body: some View { + SwiftUI.Image(systemSymbol: symbol) + .resizable() + .aspectRatio(contentMode: .fit) + .padding(5) + .padding(edge, 2) + .fontWeight(.light) + .foregroundStyle(.white) + .frame(width: 30, height: 30) + .background(Color.black.opacity(0.6).clipShape(Circle())) + } +} + +struct PostImageGalleryView: View { + + let images: [Image] + + @State private var currentIndex = 0 + + var body: some View { + ZStack { + images[currentIndex] + .resizable() + .scaledToFit() + if images.count > 1 { + HStack { + Button(action: previous) { + NavigationIcon(symbol: .chevronLeft, edge: .trailing) + } + .buttonStyle(.plain) + .padding() + + Spacer() + Button(action: next) { + NavigationIcon(symbol: .chevronRight, edge: .leading) + } + .buttonStyle(.plain) + .padding() + } + VStack { + Spacer() + HStack(spacing: 8) { + ForEach(0.. 0 { + currentIndex -= 1 + } else { + currentIndex = images.count - 1 + } + } + + private func next() { + if currentIndex < images.count - 1 { + currentIndex += 1 + } else { + currentIndex = 0 // Wrap to first image + } + } +} + +#Preview(traits: .fixedLayout(width: 300, height: 300)) { + PostImageGalleryView(images: MockImage.images.map { $0.imageToDisplay }) +} diff --git a/CHDataManagement/Views/Posts/PostList.swift b/CHDataManagement/Views/Posts/PostList.swift new file mode 100644 index 0000000..8196cb0 --- /dev/null +++ b/CHDataManagement/Views/Posts/PostList.swift @@ -0,0 +1,75 @@ +import SwiftUI + + +private struct CenteredPost: View where Content: View { + + let content: Content + + init(@ViewBuilder content: () -> Content) { + self.content = content() + } + + var body: some View { + HorizontalCenter { + content + } + .listRowBackground(PostList.background) + } +} + +struct PostList: View { + + static let background = Color(r: 2, g: 15, b: 26) + + @Binding + var posts: [Post] + + var body: some View { + List { + if posts.isEmpty { + CenteredPost { + Text("No posts yet.") + .padding() + } + .listRowSeparator(.hidden) + } + CenteredPost { + Button(action: addNewPost) { + Text("Add post") + } + .padding() + .listRowSeparator(.hidden) + } + ForEach(posts) { post in + CenteredPost { + PostView(post: post) + .frame(maxWidth: 600) + } + .listRowSeparator(.hidden) + .listRowInsets(.init(top: 0, leading: 0, bottom: 30, trailing: 0)) + } + } + .listStyle(.plain) + .background(PostList.background) + .scrollContentBackground(.hidden) + } + + private func addNewPost() { + let largestId = posts.map { $0.id }.max() ?? 0 + + let post = Post( + id: largestId + 1, + isDraft: true, + startDate: .now, + endDate: nil, + title: .init(en: "Title", de: "Titel"), + text: .init(en: "Text", de: "Text"), + tags: [], + images: []) + posts.insert(post, at: 0) + } +} + +#Preview { + PostList(posts: .constant([.mock, .fullMock])) +} diff --git a/CHDataManagement/Views/Posts/PostView.swift b/CHDataManagement/Views/Posts/PostView.swift new file mode 100644 index 0000000..cf5b462 --- /dev/null +++ b/CHDataManagement/Views/Posts/PostView.swift @@ -0,0 +1,88 @@ +import SwiftUI + +struct PostView: View { + + @Environment(\.language) + var language + + @ObservedObject + var post: Post + + @State + private var showDatePicker = false + + var body: some View { + VStack(alignment: .center) { + if !post.images.isEmpty { + PostImageGalleryView(images: post.displayImages) + .aspectRatio(1.33, contentMode: .fill) + } + VStack(alignment: .leading) { + HStack(alignment: .center, spacing: 0) { + Text(post.dateText(in: language)) + .font(.system(size: 19, weight: .semibold)) + .onTapGesture { showDatePicker = true } + Spacer() + Toggle("Draft", isOn: $post.isDraft) + } + .foregroundStyle(Color(r: 96, g: 186, b: 255)) + TextField("", text: post.title.text(for: language)) + .font(.system(size: 24, weight: .bold)) + .foregroundStyle(Color.white) + .textFieldStyle(.plain) + .lineLimit(2) + FlowHStack { + ForEach(post.tags, id: \.id) { tag in + TagView(tag: tag.name) + .onTapGesture { + remove(tag: tag) + } + } + Button(action: showTagList) { + SwiftUI.Image(systemSymbol: .plusCircleFill) + .resizable() + .aspectRatio(1, contentMode: .fit) + .frame(height: 18) + .foregroundColor(TagView.foreground) + .opacity(0.7) + .padding(.top, 3) + } + .buttonStyle(.plain) + } + TextEditor(text: post.text.text(for: language)) + .font(.body) + .foregroundStyle(Color(r: 221, g: 221, b: 221)) + .textEditorStyle(.plain) + .padding(.leading, -5) + .scrollDisabled(true) + } + .padding() + } + .background(Color(r: 4, g: 31, b: 52)) + .cornerRadius(8) + .sheet(isPresented: $showDatePicker) { + DatePickerView(post: post, showDatePicker: $showDatePicker) + } + } + + 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(Color(r: 2, g: 15, b: 26)) + .environment(\.language, ContentLanguage.german) + PostView(post: .mock) + .listRowSeparator(.hidden) + .listRowBackground(Color(r: 2, g: 15, b: 26)) + } + .listStyle(.plain) +} diff --git a/CHDataManagement/Views/Posts/TagView.swift b/CHDataManagement/Views/Posts/TagView.swift new file mode 100644 index 0000000..11586ee --- /dev/null +++ b/CHDataManagement/Views/Posts/TagView.swift @@ -0,0 +1,57 @@ +import Foundation +import SwiftUI +import SFSafeSymbols + +struct TagView: View { + + static let background = Color(r: 9, g: 62, b: 103) + + static let foreground = Color(r: 96, g: 186, b: 255) + + @Environment(\.language) + var language: ContentLanguage + + let tag: LocalizedText + + let icon: SFSymbol + + let iconSize: CGFloat + + init(tag: LocalizedText, icon: SFSymbol = .xCircleFill, iconSize: CGFloat = 12.0) { + self.tag = tag + self.icon = icon + self.iconSize = iconSize + } + + static var add: TagView { + .init(tag: LocalizedText(en: "Add", de: "Mehr"), icon: .plusCircleFill) + } + + var body: some View { + HStack { + 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(TagView.foreground) + .font(.caption2) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(TagView.background) + .cornerRadius(8) + } +} + +#Preview { + HStack { + TagView(tag: LocalizedText(en: "Some", de: "Etwas")) + .environment(\.language, ContentLanguage.german) + TagView(tag: LocalizedText(en: "Some", de: "Etwas")) + .environment(\.language, ContentLanguage.english) + TagView.add + } +} diff --git a/CHDataManagement/Views/Settings/SettingsView.swift b/CHDataManagement/Views/Settings/SettingsView.swift new file mode 100644 index 0000000..daefa0b --- /dev/null +++ b/CHDataManagement/Views/Settings/SettingsView.swift @@ -0,0 +1,136 @@ +import SwiftUI + +struct SettingsView: View { + + @Environment(\.language) + var language + + @AppStorage("contentPath") + var contentPath: String = "" + + @AppStorage("outputPath") + var outputPath: String = "" + + @EnvironmentObject + var content: Content + + @State + private var isSelectingContentFolder = false + + @State + private var showFileImporter = false + + var body: some View { + VStack(alignment: .leading) { + Text("Content Folder") + .font(.headline) + TextField("Content Folder", text: $contentPath) + Button(action: selectContentFolder) { + Text("Select folder") + } + Text("Output Folder") + .font(.headline) + TextField("Output Folder", text: $outputPath) + Button(action: selectOutputFolder) { + Text("Select folder") + } + Text("Feed") + .font(.headline) + Button(action: generateFeed) { + Text("Generate") + } + } + .padding() + .fileImporter( + isPresented: $showFileImporter, + allowedContentTypes: [.folder], + onCompletion: didSelectContentFolder) + } + + // MARK: Folder selection + + private func selectContentFolder() { + isSelectingContentFolder = true + //showFileImporter = true + savePanelUsingOpenPanel(key: "contentPathBookmark") + } + + private func selectOutputFolder() { + isSelectingContentFolder = false + //showFileImporter = true + savePanelUsingOpenPanel(key: "outputPathBookmark") + } + + private func didSelectContentFolder(_ result: Result) { + switch result { + case .success(let url): + didSelect(folder: url) + case .failure(let error): + print("Failed to select content folder: \(error)") + } + } + + private func didSelect(folder: URL) { + let path = folder.absoluteString + .replacingOccurrences(of: "file://", with: "") + if isSelectingContentFolder { + self.contentPath = path + saveSecurityScopedBookmark(folder, key: "contentPathBookmark") + } else { + self.outputPath = path + saveSecurityScopedBookmark(folder, key: "outputPathBookmark") + } + } + + // MARK: Feed + + private func generateFeed() { + guard outputPath != "" else { + print("Invalid output path") + return + } + let url = URL(fileURLWithPath: outputPath) + + guard FileManager.default.fileExists(atPath: url.path) else { + print("Missing output folder") + return + } + + content.generateFeed(for: language, bookmarkKey: "outputPathBookmark") + } + + func savePanelUsingOpenPanel(key: String) { + let panel = NSOpenPanel() + // Sets up so user can only select a single directory + panel.canChooseFiles = false + panel.canChooseDirectories = true + panel.allowsMultipleSelection = false + panel.showsHiddenFiles = false + panel.title = "Select Save Directory" + panel.prompt = "Select Save Directory" + + let response = panel.runModal() + guard response == .OK else { + + return + } + guard let url = panel.url else { + return + } + saveSecurityScopedBookmark(url, key: key) + } + + func saveSecurityScopedBookmark(_ url: URL, key: String) { + do { + let bookmarkData = try url.bookmarkData(options: .withSecurityScope, includingResourceValuesForKeys: nil, relativeTo: nil) + UserDefaults.standard.set(bookmarkData, forKey: key) + print("Security-scoped bookmark saved.") + } catch { + print("Failed to create security-scoped bookmark: \(error)") + } + } +} + +#Preview { + SettingsView() +}