First version
This commit is contained in:
parent
7c812de089
commit
0989f06d87
@ -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 = "<group>"; };
|
||||
E227BE292C355AF700F0CB47 /* Use.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Use.swift; sourceTree = "<group>"; };
|
||||
E227BE2C2C3E976D00F0CB47 /* Path.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Path.swift; sourceTree = "<group>"; };
|
||||
E227BE2E2C3E97DF00F0CB47 /* Svg+Dimensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Svg+Dimensions.swift"; sourceTree = "<group>"; };
|
||||
E227BE302C3E9B2700F0CB47 /* MetricIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricIcon.swift; sourceTree = "<group>"; };
|
||||
E227BE322C3EA51500F0CB47 /* TagLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagLink.swift; sourceTree = "<group>"; };
|
||||
E227BE342C415EC000F0CB47 /* Source+Attributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Source+Attributes.swift"; sourceTree = "<group>"; };
|
||||
E227BE362C415F8900F0CB47 /* Image+Attributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Image+Attributes.swift"; sourceTree = "<group>"; };
|
||||
E227BE382C41611100F0CB47 /* ArticleImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArticleImage.swift; sourceTree = "<group>"; };
|
||||
E24252022C5163CF0029FF16 /* Importer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Importer.swift; sourceTree = "<group>"; };
|
||||
E24252052C51684E0029FF16 /* GenericMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GenericMetadata.swift; sourceTree = "<group>"; };
|
||||
E24252072C5168750029FF16 /* GenericMetadata+Localized.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GenericMetadata+Localized.swift"; sourceTree = "<group>"; };
|
||||
E24252092C52C9260029FF16 /* ContentLanguage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentLanguage.swift; sourceTree = "<group>"; };
|
||||
E2581DEC2C75202400F1F079 /* Tag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tag.swift; sourceTree = "<group>"; };
|
||||
E2581DEE2C75203800F1F079 /* TagsSubtitle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagsSubtitle.swift; sourceTree = "<group>"; };
|
||||
E2581DF02C7523F400F1F079 /* ImportableTag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImportableTag.swift; sourceTree = "<group>"; };
|
||||
E28101182C50E03A0066F5BE /* EntryContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EntryContentView.swift; sourceTree = "<group>"; };
|
||||
E2A21C002CB16A820060935B /* PostView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostView.swift; sourceTree = "<group>"; };
|
||||
E2A21C022CB16C220060935B /* Environment+Language.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Environment+Language.swift"; sourceTree = "<group>"; };
|
||||
E2A21C042CB176670060935B /* LocalizedText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizedText.swift; sourceTree = "<group>"; };
|
||||
E2A21C072CB17B810060935B /* TagView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagView.swift; sourceTree = "<group>"; };
|
||||
E2A21C0B2CB17C150060935B /* TagListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagListView.swift; sourceTree = "<group>"; };
|
||||
E2A21C0D2CB189D70060935B /* Color+RGB.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+RGB.swift"; sourceTree = "<group>"; };
|
||||
E2A21C0F2CB18B390060935B /* FlowHStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlowHStack.swift; sourceTree = "<group>"; };
|
||||
E2A21C112CB18D520060935B /* DatePickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatePickerView.swift; sourceTree = "<group>"; };
|
||||
@ -108,15 +80,14 @@
|
||||
E2A21C4C2CBB16B50060935B /* ImagesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagesView.swift; sourceTree = "<group>"; };
|
||||
E2A21C4E2CBB29E50060935B /* ImageDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageDetailsView.swift; sourceTree = "<group>"; };
|
||||
E2A21C502CBBD53C0060935B /* FileResource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileResource.swift; sourceTree = "<group>"; };
|
||||
E2A21C532CBBF87A0060935B /* FilesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilesView.swift; sourceTree = "<group>"; };
|
||||
E2A21C552CBBF9880060935B /* FlexibleColumnView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlexibleColumnView.swift; sourceTree = "<group>"; };
|
||||
E2A9CB7D2C7BCF2A005C89CC /* Page.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Page.swift; sourceTree = "<group>"; };
|
||||
E2B85F372C4289F10047CD0C /* TopNavigationBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TopNavigationBar.swift; sourceTree = "<group>"; };
|
||||
E2B85F3A2C428F0D0047CD0C /* Post.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Post.swift; sourceTree = "<group>"; };
|
||||
E2B85F3C2C4293F80047CD0C /* Feed.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Feed.swift; sourceTree = "<group>"; };
|
||||
E2B85F402C4294790047CD0C /* PageHead.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageHead.swift; sourceTree = "<group>"; };
|
||||
E2B85F422C4294F60047CD0C /* FeedEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedEntry.swift; sourceTree = "<group>"; };
|
||||
E2B85F442C429ED60047CD0C /* ImageGallery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageGallery.swift; sourceTree = "<group>"; };
|
||||
E2B85F512C4BB3220047CD0C /* OptionalTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptionalTextField.swift; sourceTree = "<group>"; };
|
||||
E2B85F532C4BCCAC0047CD0C /* DetailTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailTextField.swift; sourceTree = "<group>"; };
|
||||
E2B85F562C4BD0BB0047CD0C /* Binding+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Binding+Extension.swift"; sourceTree = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
@ -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 = "<group>";
|
||||
};
|
||||
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 = "<group>";
|
||||
};
|
||||
E24252042C5168430029FF16 /* Import */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
E24252022C5163CF0029FF16 /* Importer.swift */,
|
||||
E2581DF02C7523F400F1F079 /* ImportableTag.swift */,
|
||||
E24252052C51684E0029FF16 /* GenericMetadata.swift */,
|
||||
E24252072C5168750029FF16 /* GenericMetadata+Localized.swift */,
|
||||
);
|
||||
path = Import;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
E2A21C062CB17B7A0060935B /* Unused */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
E2A21C0B2CB17C150060935B /* TagListView.swift */,
|
||||
E2B85F512C4BB3220047CD0C /* OptionalTextField.swift */,
|
||||
E2B85F532C4BCCAC0047CD0C /* DetailTextField.swift */,
|
||||
E28101182C50E03A0066F5BE /* EntryContentView.swift */,
|
||||
);
|
||||
path = Unused;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
E2A21C322CB5BCAC0060935B /* Pages */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@ -219,10 +152,19 @@
|
||||
children = (
|
||||
E2A21C4C2CBB16B50060935B /* ImagesView.swift */,
|
||||
E2A21C4E2CBB29E50060935B /* ImageDetailsView.swift */,
|
||||
E2A21C552CBBF9880060935B /* FlexibleColumnView.swift */,
|
||||
);
|
||||
path = Images;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
E2A21C522CBBF86D0060935B /* Files */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
E2A21C532CBBF87A0060935B /* FilesView.swift */,
|
||||
);
|
||||
path = Files;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
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 = "<group>";
|
||||
@ -250,7 +191,6 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
E2B85F3C2C4293F80047CD0C /* Feed.swift */,
|
||||
E227BE272C3330CE00F0CB47 /* Article.swift */,
|
||||
);
|
||||
path = Pages;
|
||||
sourceTree = "<group>";
|
||||
@ -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" */;
|
||||
|
@ -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",
|
||||
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
14
CHDataManagement/Extensions/Binding+Extension.swift
Normal file
14
CHDataManagement/Extensions/Binding+Extension.swift
Normal file
@ -0,0 +1,14 @@
|
||||
import SwiftUI
|
||||
|
||||
public extension Binding where Value: Equatable, Value: Sendable {
|
||||
|
||||
init(_ source: Binding<Value?>, replacingNilWith nilProxy: Value) {
|
||||
self.init(
|
||||
get: { source.wrappedValue ?? nilProxy },
|
||||
set: { newValue in
|
||||
if newValue == nilProxy { source.wrappedValue = nil }
|
||||
else { source.wrappedValue = newValue }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
15
CHDataManagement/Extensions/Color+RGB.swift
Normal file
15
CHDataManagement/Extensions/Color+RGB.swift
Normal file
@ -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)
|
||||
}
|
||||
}
|
15
CHDataManagement/Extensions/Environment+Language.swift
Normal file
15
CHDataManagement/Extensions/Environment+Language.swift
Normal file
@ -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 }
|
||||
}
|
||||
}
|
11
CHDataManagement/Extensions/String+Extensions.swift
Normal file
11
CHDataManagement/Extensions/String+Extensions.swift
Normal file
@ -0,0 +1,11 @@
|
||||
|
||||
extension String {
|
||||
|
||||
func htmlEscaped() -> String {
|
||||
replacingOccurrences(of: "&", with: "&")
|
||||
.replacingOccurrences(of: "\"", with: """)
|
||||
.replacingOccurrences(of: "'", with: "'")
|
||||
.replacingOccurrences(of: "<", with: "<")
|
||||
.replacingOccurrences(of: ">", with: ">")
|
||||
}
|
||||
}
|
153
CHDataManagement/Import/GenericMetadata+Localized.swift
Normal file
153
CHDataManagement/Import/GenericMetadata+Localized.swift
Normal file
@ -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 {
|
||||
|
||||
}
|
137
CHDataManagement/Import/GenericMetadata.swift
Normal file
137
CHDataManagement/Import/GenericMetadata.swift
Normal file
@ -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 ``
|
||||
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<String>?
|
||||
|
||||
/**
|
||||
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<String>?
|
||||
|
||||
/**
|
||||
Additional images required by the element.
|
||||
|
||||
These images are specified as: `source_name destination_name width (height)`.
|
||||
*/
|
||||
let images: Set<String>?
|
||||
|
||||
/**
|
||||
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 {
|
||||
|
||||
}
|
33
CHDataManagement/Import/ImportableTag.swift
Normal file
33
CHDataManagement/Import/ImportableTag.swift
Normal file
@ -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 {
|
||||
|
||||
}
|
134
CHDataManagement/Import/Importer.swift
Normal file
134
CHDataManagement/Import/Importer.swift
Normal file
@ -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)!
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
104
CHDataManagement/Model/Content.swift
Normal file
104
CHDataManagement/Model/Content.swift
Normal file
@ -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)")
|
||||
}
|
||||
}
|
||||
|
||||
}
|
8
CHDataManagement/Model/ContentLanguage.swift
Normal file
8
CHDataManagement/Model/ContentLanguage.swift
Normal file
@ -0,0 +1,8 @@
|
||||
import Foundation
|
||||
|
||||
enum ContentLanguage: String {
|
||||
|
||||
case english = "en"
|
||||
|
||||
case german = "de"
|
||||
}
|
16
CHDataManagement/Model/FileResource.swift
Normal file
16
CHDataManagement/Model/FileResource.swift
Normal file
@ -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
|
||||
}
|
||||
}
|
91
CHDataManagement/Model/ImageResource.swift
Normal file
91
CHDataManagement/Model/ImageResource.swift
Normal file
@ -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))
|
||||
}
|
||||
}
|
48
CHDataManagement/Model/LocalizedText.swift
Normal file
48
CHDataManagement/Model/LocalizedText.swift
Normal file
@ -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<String> {
|
||||
Binding(
|
||||
get: {
|
||||
self.getText(for: language)
|
||||
},
|
||||
set: { newValue in
|
||||
self.set(text: newValue, for: language)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
89
CHDataManagement/Model/Page.swift
Normal file
89
CHDataManagement/Model/Page.swift
Normal file
@ -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<String> = []
|
||||
|
||||
/**
|
||||
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<String> = []
|
||||
|
||||
/**
|
||||
Additional images required by the element.
|
||||
|
||||
These images are specified as: `source_name destination_name width (height)`.
|
||||
*/
|
||||
@Published
|
||||
var images: Set<String> = []
|
||||
|
||||
init(id: String, isDraft: Bool, metadata: [LocalizedPage], externalFiles: Set<String> = [], requiredFiles: Set<String> = [], images: Set<String> = []) {
|
||||
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)
|
||||
}
|
||||
}
|
170
CHDataManagement/Model/Post.swift
Normal file
170
CHDataManagement/Model/Post.swift
Normal file
@ -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 }
|
||||
}
|
||||
}
|
69
CHDataManagement/Model/Tag.swift
Normal file
69
CHDataManagement/Model/Tag.swift
Normal file
@ -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
|
||||
}
|
||||
}
|
41
CHDataManagement/Page Elements/FeedEntry.swift
Normal file
41
CHDataManagement/Page Elements/FeedEntry.swift
Normal file
@ -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 += "<div class='card'>"
|
||||
ImageGallery(id: data.entryId, images: data.images)
|
||||
.addContent(to: &result)
|
||||
|
||||
if let url = data.link?.url {
|
||||
result += "<div class=\"card-content\" onclick=\"window.location.href='\(url)'\">"
|
||||
} else {
|
||||
result += "<div class=\"card-content\">"
|
||||
}
|
||||
result += "<h3>\(data.textAboveTitle)</h3>"
|
||||
if let title = data.title {
|
||||
result += "<h2>\(title.htmlEscaped())</h2>"
|
||||
}
|
||||
if !data.tags.isEmpty {
|
||||
result += "<div class=\"tags\">"
|
||||
for tag in data.tags {
|
||||
result += "<a class=\"tag\" href=\"\(tag.url)\">\(tag.name)</a>"
|
||||
}
|
||||
result += "</div>"
|
||||
}
|
||||
for paragraph in data.text {
|
||||
result += "<p>\(paragraph)</p>"
|
||||
}
|
||||
if let url = data.link {
|
||||
result += "<div class=\"link-center\"><div class=\"link\">\(url.text)</div></div>"
|
||||
}
|
||||
result += "</div></div>" // Closes card-content and card
|
||||
}
|
||||
}
|
44
CHDataManagement/Page Elements/FeedEntryContent.swift
Normal file
44
CHDataManagement/Page Elements/FeedEntryContent.swift
Normal file
@ -0,0 +1,44 @@
|
||||
//import Elementary
|
||||
|
||||
struct FeedEntryContent<Content> {
|
||||
|
||||
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 += "<div class=\"card-content\" onclick=\"window.location.href='\(url)'\">"
|
||||
} else {
|
||||
result += "<div class=\"card-content\">"
|
||||
}
|
||||
inner()
|
||||
result += "</div>"
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
*/
|
51
CHDataManagement/Page Elements/FeedEntryData.swift
Normal file
51
CHDataManagement/Page Elements/FeedEntryData.swift
Normal file
@ -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
|
||||
|
||||
}
|
||||
}
|
83
CHDataManagement/Page Elements/ImageGallery.swift
Normal file
83
CHDataManagement/Page Elements/ImageGallery.swift
Normal file
@ -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 += "<div id=\"s\(id)\" class=\"swiper\"><div class=\"swiper-wrapper\">"
|
||||
|
||||
guard images.count > 1 else {
|
||||
let image = images[0]
|
||||
result += "<div class=\"swiper-slide\"><img src=\(image.mainImageUrl) loading=\"lazy\" alt=\"\(image.altText.htmlEscaped())\"></div>"
|
||||
result += "</div></div>" // Close swiper, swiper-wrapper
|
||||
return
|
||||
}
|
||||
|
||||
for image in images {
|
||||
// TODO: Use different images based on device
|
||||
result += "<div class=\"swiper-slide\">"
|
||||
result += "<img src=\(image.mainImageUrl) loading=\"lazy\" alt=\"\(image.altText.htmlEscaped())\">"
|
||||
result += "<div class=\"swiper-lazy-preloader swiper-lazy-preloader-white\"></div>"
|
||||
result += "</div>" // Close swiper-slide
|
||||
}
|
||||
|
||||
result += "<div class=\"swiper-button-next\"></div>"
|
||||
result += "<div class=\"swiper-button-prev\"></div>"
|
||||
result += "<div class=\"swiper-pagination\"></div>"
|
||||
result += "</div></div>" // 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")) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
*/
|
36
CHDataManagement/Page Elements/PageHead.swift
Normal file
36
CHDataManagement/Page Elements/PageHead.swift
Normal file
@ -0,0 +1,36 @@
|
||||
import Foundation
|
||||
//import Elementary
|
||||
|
||||
struct PageHead {
|
||||
|
||||
let title: String
|
||||
|
||||
let description: String
|
||||
|
||||
var content: String {
|
||||
"""
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>\(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="/assets/swiper/swiper.css" />
|
||||
<link rel="stylesheet" href="/assets/css/style.css" />
|
||||
</head>
|
||||
"""
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
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"))
|
||||
}
|
||||
}
|
||||
*/
|
79
CHDataManagement/Pages/Feed.swift
Normal file
79
CHDataManagement/Pages/Feed.swift
Normal file
@ -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 += "<!DOCTYPE html><html lang=\"\(language.rawValue)\">"
|
||||
let head = PageHead(
|
||||
title: title.getText(for: language),
|
||||
description: description.getText(for: language))
|
||||
result += head.content
|
||||
result += "<body>"
|
||||
addNavbar(to: &result)
|
||||
result += "<div class=\"content\"><div style=\"height: 70px;\"></div>"
|
||||
for post in posts {
|
||||
FeedEntry(data: post)
|
||||
.addContent(to: &result)
|
||||
}
|
||||
|
||||
addSwiperInits(to: &result)
|
||||
result += "</div></body></html>" // Close content
|
||||
return result
|
||||
}
|
||||
|
||||
#warning("TODO: Set correct navigation links and texts")
|
||||
private func addNavbar(to result: inout String) {
|
||||
result += "<nav class=\"navbar\"><div class=\"navbar-fade\"></div><div class=\"nav-center\">"
|
||||
let middleIndex = navigationItems.count / 2
|
||||
let leftNavigationItems = navigationItems[..<middleIndex]
|
||||
let rightNavigationItems = navigationItems[middleIndex...]
|
||||
|
||||
for item in leftNavigationItems {
|
||||
result += "<a class=\"nav-animate\" href=\"\(item.url.getText(for: language))\">\(item.text.getText(for: language))</a>"
|
||||
}
|
||||
|
||||
result += "<a id=\"nav-image\" href=\"/\">"
|
||||
result += "<img class=\"navbar-icon\" src=\"\(navigationIconPath)\" alt=\"\(iconDescription.getText(for: language))\">"
|
||||
|
||||
for item in rightNavigationItems {
|
||||
result += "<a class=\"nav-animate\" href=\"\(item.url.getText(for: language))\">\(item.text.getText(for: language))</a>"
|
||||
}
|
||||
result += "</div></nav>" // Close nav-center, navbar
|
||||
}
|
||||
|
||||
private func addSwiperInits(to result: inout String) {
|
||||
if posts.contains(where: { $0.images.count > 1 }) {
|
||||
result += "<script src=\"/assets/swiper/swiper.min.js\"></script><script>"
|
||||
for post in posts {
|
||||
guard post.images.count > 1 else {
|
||||
continue
|
||||
}
|
||||
result += ImageGallery.swiperInit(id: post.entryId)
|
||||
}
|
||||
result += "</script>"
|
||||
}
|
||||
}
|
||||
}
|
19
CHDataManagement/Preview Content/MockImage.swift
Normal file
19
CHDataManagement/Preview Content/MockImage.swift
Normal file
@ -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
|
||||
}
|
||||
}
|
18
CHDataManagement/Preview Content/Page+Mock.swift
Normal file
18
CHDataManagement/Preview Content/Page+Mock.swift
Normal file
@ -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: [])
|
||||
}
|
||||
}
|
||||
|
53
CHDataManagement/Preview Content/Post+Mock.swift
Normal file
53
CHDataManagement/Preview Content/Post+Mock.swift
Normal file
@ -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
|
||||
)
|
||||
}
|
||||
}
|
21
CHDataManagement/Preview Content/Preview Assets.xcassets/image1.imageset/Contents.json
vendored
Normal file
21
CHDataManagement/Preview Content/Preview Assets.xcassets/image1.imageset/Contents.json
vendored
Normal file
@ -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
|
||||
}
|
||||
}
|
BIN
CHDataManagement/Preview Content/Preview Assets.xcassets/image1.imageset/image1.jpg
vendored
Normal file
BIN
CHDataManagement/Preview Content/Preview Assets.xcassets/image1.imageset/image1.jpg
vendored
Normal file
Binary file not shown.
After Width: | Height: | Size: 5.6 MiB |
21
CHDataManagement/Preview Content/Preview Assets.xcassets/image2.imageset/Contents.json
vendored
Normal file
21
CHDataManagement/Preview Content/Preview Assets.xcassets/image2.imageset/Contents.json
vendored
Normal file
@ -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
|
||||
}
|
||||
}
|
BIN
CHDataManagement/Preview Content/Preview Assets.xcassets/image2.imageset/image2.jpg
vendored
Normal file
BIN
CHDataManagement/Preview Content/Preview Assets.xcassets/image2.imageset/image2.jpg
vendored
Normal file
Binary file not shown.
After Width: | Height: | Size: 3.5 MiB |
21
CHDataManagement/Preview Content/Preview Assets.xcassets/image3.imageset/Contents.json
vendored
Normal file
21
CHDataManagement/Preview Content/Preview Assets.xcassets/image3.imageset/Contents.json
vendored
Normal file
@ -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
|
||||
}
|
||||
}
|
BIN
CHDataManagement/Preview Content/Preview Assets.xcassets/image3.imageset/image3.jpg
vendored
Normal file
BIN
CHDataManagement/Preview Content/Preview Assets.xcassets/image3.imageset/image3.jpg
vendored
Normal file
Binary file not shown.
After Width: | Height: | Size: 5.3 MiB |
21
CHDataManagement/Preview Content/Preview Assets.xcassets/image4.imageset/Contents.json
vendored
Normal file
21
CHDataManagement/Preview Content/Preview Assets.xcassets/image4.imageset/Contents.json
vendored
Normal file
@ -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
|
||||
}
|
||||
}
|
BIN
CHDataManagement/Preview Content/Preview Assets.xcassets/image4.imageset/image4.jpg
vendored
Normal file
BIN
CHDataManagement/Preview Content/Preview Assets.xcassets/image4.imageset/image4.jpg
vendored
Normal file
Binary file not shown.
After Width: | Height: | Size: 4.2 MiB |
20
CHDataManagement/Views/Files/FilesView.swift
Normal file
20
CHDataManagement/Views/Files/FilesView.swift
Normal file
@ -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()
|
||||
}
|
53
CHDataManagement/Views/Generic/FlowHStack.swift
Normal file
53
CHDataManagement/Views/Generic/FlowHStack.swift
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
}
|
33
CHDataManagement/Views/Generic/HorizontalCenter.swift
Normal file
33
CHDataManagement/Views/Generic/HorizontalCenter.swift
Normal file
@ -0,0 +1,33 @@
|
||||
import SwiftUI
|
||||
|
||||
/**
|
||||
A view that centers the content horizontally using an `HStack`
|
||||
*/
|
||||
struct HorizontalCenter<Content> : 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")
|
||||
}
|
||||
}
|
49
CHDataManagement/Views/Images/FlexibleColumnView.swift
Normal file
49
CHDataManagement/Views/Images/FlexibleColumnView.swift
Normal file
@ -0,0 +1,49 @@
|
||||
import SwiftUI
|
||||
|
||||
struct FlexibleColumnView<Content, Inner>: 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)
|
||||
}
|
||||
}
|
59
CHDataManagement/Views/Images/ImageDetailsView.swift
Normal file
59
CHDataManagement/Views/Images/ImageDetailsView.swift
Normal file
@ -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!)
|
||||
}
|
62
CHDataManagement/Views/Images/ImagesView.swift
Normal file
62
CHDataManagement/Views/Images/ImagesView.swift
Normal file
@ -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)
|
||||
}
|
17
CHDataManagement/Views/Pages/PageDetailView.swift
Normal file
17
CHDataManagement/Views/Pages/PageDetailView.swift
Normal file
@ -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))
|
||||
}
|
49
CHDataManagement/Views/Posts/DatePickerView.swift
Normal file
49
CHDataManagement/Views/Posts/DatePickerView.swift
Normal file
@ -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))
|
||||
}
|
83
CHDataManagement/Views/Posts/PostImageGalleryView.swift
Normal file
83
CHDataManagement/Views/Posts/PostImageGalleryView.swift
Normal file
@ -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..<images.count, id: \.self) { index in
|
||||
Circle()
|
||||
.fill(index == currentIndex ? Color.white : Color.gray) // Change color based on current index
|
||||
.frame(width: 10, height: 10)
|
||||
}
|
||||
}
|
||||
.padding(.bottom, 10)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func previous() {
|
||||
if currentIndex > 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 })
|
||||
}
|
75
CHDataManagement/Views/Posts/PostList.swift
Normal file
75
CHDataManagement/Views/Posts/PostList.swift
Normal file
@ -0,0 +1,75 @@
|
||||
import SwiftUI
|
||||
|
||||
|
||||
private struct CenteredPost<Content>: 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]))
|
||||
}
|
88
CHDataManagement/Views/Posts/PostView.swift
Normal file
88
CHDataManagement/Views/Posts/PostView.swift
Normal file
@ -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)
|
||||
}
|
57
CHDataManagement/Views/Posts/TagView.swift
Normal file
57
CHDataManagement/Views/Posts/TagView.swift
Normal file
@ -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
|
||||
}
|
||||
}
|
136
CHDataManagement/Views/Settings/SettingsView.swift
Normal file
136
CHDataManagement/Views/Settings/SettingsView.swift
Normal file
@ -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<URL, any Error>) {
|
||||
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()
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user