First version

This commit is contained in:
Christoph Hagen 2024-10-14 19:22:32 +02:00
parent 7c812de089
commit 0989f06d87
51 changed files with 2477 additions and 234 deletions

View File

@ -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" */;

View File

@ -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",

View File

@ -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()
}
}

View File

@ -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)
}

View 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 }
}
)
}
}

View 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)
}
}

View 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 }
}
}

View File

@ -0,0 +1,11 @@
extension String {
func htmlEscaped() -> String {
replacingOccurrences(of: "&", with: "&amp;")
.replacingOccurrences(of: "\"", with: "&quot;")
.replacingOccurrences(of: "'", with: "&#39;")
.replacingOccurrences(of: "<", with: "&lt;")
.replacingOccurrences(of: ">", with: "&gt;")
}
}

View 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 {
}

View 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 `![page](page_id)`
for thumbnail previews or `[text](page:page_id)` for simple links.
If no custom id is set, then the name of the element folder is used.
*/
let customId: String?
/**
The author of the content.
If no author is set, then the author from the parent element is used.
*/
let author: String?
/**
The (start) date of the element.
The date is printed on content pages and may also used for sorting elements,
depending on the `useManualSorting` property of the parent.
*/
let date: String?
/**
The end date of the element.
This property can be used to specify a date range for a content page.
*/
let endDate: String?
/**
The deployment state of the page.
- Note: This property defaults to ``PageState.standard`
*/
let state: String?
/**
The sort index of the page for manual sorting.
- Note: This property is only used (and must be set) if `useManualSorting` option of the parent is set.
*/
let sortIndex: Int?
/**
All files which may occur in content but is stored externally.
Missing files which would otherwise produce a warning are ignored when included here.
- Note: This property defaults to an empty set.
*/
let externalFiles: Set<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 {
}

View 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 {
}

View 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)!
}
}

View File

@ -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
}
}

View 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)")
}
}
}

View File

@ -0,0 +1,8 @@
import Foundation
enum ContentLanguage: String {
case english = "en"
case german = "de"
}

View 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
}
}

View 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))
}
}

View 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)
}
)
}
}

View 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)
}
}

View 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 }
}
}

View 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
}
}

View 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
}
}

View 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
}
}
}
}
*/

View 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
}
}

View 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")) { }
}
}
}
*/

View 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"))
}
}
*/

View 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>"
}
}
}

View 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
}
}

View 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: [])
}
}

View 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
)
}
}

View 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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 MiB

View 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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 MiB

View 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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 MiB

View 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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 MiB

View 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()
}

View 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
}
}
}

View 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")
}
}

View 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)
}
}

View 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!)
}

View 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)
}

View 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))
}

View 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))
}

View 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 })
}

View 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]))
}

View 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)
}

View 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
}
}

View 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()
}