Improve tag and images view, save website settings

This commit is contained in:
Christoph Hagen 2024-12-02 13:08:52 +01:00
parent 1261ea534b
commit 4440b2ce0d
22 changed files with 576 additions and 144 deletions

View File

@ -27,6 +27,10 @@
E21850312CFAF8880090B18B /* Content+Import.swift in Sources */ = {isa = PBXBuildFile; fileRef = E21850302CFAF8840090B18B /* Content+Import.swift */; };
E21850332CFAFA2F0090B18B /* WebsiteData.swift in Sources */ = {isa = PBXBuildFile; fileRef = E21850322CFAFA200090B18B /* WebsiteData.swift */; };
E21850352CFAFA5A0090B18B /* WebsiteDataFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = E21850342CFAFA570090B18B /* WebsiteDataFile.swift */; };
E21850372CFCA55F0090B18B /* LocalizedWebsiteData.swift in Sources */ = {isa = PBXBuildFile; fileRef = E21850362CFCA5580090B18B /* LocalizedWebsiteData.swift */; };
E21850392CFCA6C00090B18B /* WebsiteData+Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = E21850382CFCA6BA0090B18B /* WebsiteData+Mock.swift */; };
E218503B2CFCFBE70090B18B /* WebsiteData+Storage.swift in Sources */ = {isa = PBXBuildFile; fileRef = E218503A2CFCFBDE0090B18B /* WebsiteData+Storage.swift */; };
E218503D2CFCFD910090B18B /* LocalizedSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E218503C2CFCFD8C0090B18B /* LocalizedSettingsView.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 */; };
@ -34,6 +38,10 @@
E242520A2C52C9260029FF16 /* ContentLanguage.swift in Sources */ = {isa = PBXBuildFile; fileRef = E24252092C52C9260029FF16 /* ContentLanguage.swift */; };
E2581DED2C75202400F1F079 /* Tag.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2581DEC2C75202400F1F079 /* Tag.swift */; };
E2581DF12C7523F400F1F079 /* ImportableTag.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2581DF02C7523F400F1F079 /* ImportableTag.swift */; };
E25DA5092CFD964E00AEF16D /* TagContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA5082CFD964E00AEF16D /* TagContentView.swift */; };
E25DA50B2CFD988100AEF16D /* PageTagAssignmentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA50A2CFD988100AEF16D /* PageTagAssignmentView.swift */; };
E25DA50D2CFD9BA200AEF16D /* PostTagAssignmentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA50C2CFD9B9C00AEF16D /* PostTagAssignmentView.swift */; };
E25DA50F2CFDD76B00AEF16D /* ImagesContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA50E2CFDD76B00AEF16D /* ImagesContentView.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 */; };
@ -107,6 +115,10 @@
E21850302CFAF8840090B18B /* Content+Import.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Content+Import.swift"; sourceTree = "<group>"; };
E21850322CFAFA200090B18B /* WebsiteData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebsiteData.swift; sourceTree = "<group>"; };
E21850342CFAFA570090B18B /* WebsiteDataFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebsiteDataFile.swift; sourceTree = "<group>"; };
E21850362CFCA5580090B18B /* LocalizedWebsiteData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizedWebsiteData.swift; sourceTree = "<group>"; };
E21850382CFCA6BA0090B18B /* WebsiteData+Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WebsiteData+Mock.swift"; sourceTree = "<group>"; };
E218503A2CFCFBDE0090B18B /* WebsiteData+Storage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WebsiteData+Storage.swift"; sourceTree = "<group>"; };
E218503C2CFCFD8C0090B18B /* LocalizedSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizedSettingsView.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>"; };
@ -114,6 +126,10 @@
E2581DEC2C75202400F1F079 /* Tag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tag.swift; sourceTree = "<group>"; };
E2581DF02C7523F400F1F079 /* ImportableTag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImportableTag.swift; sourceTree = "<group>"; };
E25A0B882CE4021400F33674 /* LocalizedPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizedPage.swift; sourceTree = "<group>"; };
E25DA5082CFD964E00AEF16D /* TagContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagContentView.swift; sourceTree = "<group>"; };
E25DA50A2CFD988100AEF16D /* PageTagAssignmentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageTagAssignmentView.swift; sourceTree = "<group>"; };
E25DA50C2CFD9B9C00AEF16D /* PostTagAssignmentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostTagAssignmentView.swift; sourceTree = "<group>"; };
E25DA50E2CFDD76B00AEF16D /* ImagesContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagesContentView.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>"; };
@ -202,6 +218,7 @@
E2A21C342CB9A3CA0060935B /* Settings */ = {
isa = PBXGroup;
children = (
E218503C2CFCFD8C0090B18B /* LocalizedSettingsView.swift */,
E2A21C352CB9A3D70060935B /* SettingsView.swift */,
);
path = Settings;
@ -222,6 +239,7 @@
isa = PBXGroup;
children = (
E2A21C4C2CBB16B50060935B /* ImagesView.swift */,
E25DA50E2CFDD76B00AEF16D /* ImagesContentView.swift */,
E2A21C4E2CBB29E50060935B /* ImageDetailsView.swift */,
E2A21C552CBBF9880060935B /* FlexibleColumnView.swift */,
);
@ -254,8 +272,11 @@
E2A9CB7F2C7E686C005C89CC /* Tags */ = {
isa = PBXGroup;
children = (
E25DA50C2CFD9B9C00AEF16D /* PostTagAssignmentView.swift */,
E2A37D282CED2C6A0000979F /* TagsListView.swift */,
E2A37D2A2CED2CC30000979F /* TagDetailView.swift */,
E25DA5082CFD964E00AEF16D /* TagContentView.swift */,
E25DA50A2CFD988100AEF16D /* PageTagAssignmentView.swift */,
);
path = Tags;
sourceTree = "<group>";
@ -264,6 +285,8 @@
isa = PBXGroup;
children = (
E21850322CFAFA200090B18B /* WebsiteData.swift */,
E218503A2CFCFBDE0090B18B /* WebsiteData+Storage.swift */,
E21850362CFCA5580090B18B /* LocalizedWebsiteData.swift */,
E2E06DFA2CA4A6570019C2AF /* Content.swift */,
E218502E2CFAF6990090B18B /* Content+Generate.swift */,
E21850302CFAF8840090B18B /* Content+Import.swift */,
@ -388,6 +411,7 @@
E2DD047C2C276F32003BFF1F /* Preview Content */ = {
isa = PBXGroup;
children = (
E21850382CFCA6BA0090B18B /* WebsiteData+Mock.swift */,
E218500A2CEE02FA0090B18B /* Content+Mock.swift */,
E2A37D1A2CEA45530000979F /* Tag+Mock.swift */,
E2A21C1F2CB28ED20060935B /* MockImage.swift */,
@ -508,6 +532,7 @@
E2A37D152CE68BEC0000979F /* PostFile.swift in Sources */,
E218501B2CEE59EC0090B18B /* Tag+Storage.swift in Sources */,
E2A21C032CB16C290060935B /* Environment+Language.swift in Sources */,
E21850392CFCA6C00090B18B /* WebsiteData+Mock.swift in Sources */,
E21850272CF3B42D0090B18B /* PostDetailView.swift in Sources */,
E2A37D172CE73F1A0000979F /* TagFile.swift in Sources */,
E2A37D292CED2C6A0000979F /* TagsListView.swift in Sources */,
@ -522,8 +547,10 @@
E2A21C4D2CBB16B50060935B /* ImagesView.swift in Sources */,
E2A21C202CB28ED20060935B /* MockImage.swift in Sources */,
E2A21C2C2CB2BB250060935B /* PostList.swift in Sources */,
E25DA50B2CFD988100AEF16D /* PageTagAssignmentView.swift in Sources */,
E21850112CEE17070090B18B /* Page+Storage.swift in Sources */,
E218500D2CEE07180090B18B /* ColorPalette.swift in Sources */,
E218503B2CFCFBE70090B18B /* WebsiteData+Storage.swift in Sources */,
E21850352CFAFA5A0090B18B /* WebsiteDataFile.swift in Sources */,
E21850132CEE541D0090B18B /* Post+Storage.swift in Sources */,
E2B85F412C4294790047CD0C /* PageHead.swift in Sources */,
@ -532,10 +559,13 @@
E2E06DFB2CA4A65E0019C2AF /* Content.swift in Sources */,
E218500B2CEE02FD0090B18B /* Content+Mock.swift in Sources */,
E2A21C3B2CB9D9A60060935B /* ImageResource.swift in Sources */,
E218503D2CFCFD910090B18B /* LocalizedSettingsView.swift in Sources */,
E2A21C302CB490F90060935B /* HorizontalCenter.swift in Sources */,
E21850372CFCA55F0090B18B /* LocalizedWebsiteData.swift in Sources */,
E2DD04742C276F31003BFF1F /* CHDataManagementApp.swift in Sources */,
E218501F2CEE6DAC0090B18B /* ImagePickerView.swift in Sources */,
E2A37D1B2CEA45560000979F /* Tag+Mock.swift in Sources */,
E25DA50F2CFDD76B00AEF16D /* ImagesContentView.swift in Sources */,
E2A21C482CBAF88B0060935B /* String+Extensions.swift in Sources */,
E2A37D1F2CEA94370000979F /* Optional+Extensions.swift in Sources */,
E2A21C512CBBD53F0060935B /* FileResource.swift in Sources */,
@ -545,7 +575,9 @@
E218502D2CF791440090B18B /* PostImagesView.swift in Sources */,
E2B85F572C4BD0BB0047CD0C /* Binding+Extension.swift in Sources */,
E2B85F432C4294F60047CD0C /* FeedEntry.swift in Sources */,
E25DA5092CFD964E00AEF16D /* TagContentView.swift in Sources */,
E2A21C0E2CB189DC0060935B /* Color+RGB.swift in Sources */,
E25DA50D2CFD9BA200AEF16D /* PostTagAssignmentView.swift in Sources */,
E2A21C362CB9A3D70060935B /* SettingsView.swift in Sources */,
E2A21C012CB16A820060935B /* PostView.swift in Sources */,
E2A21C052CB1766C0060935B /* LocalizedText.swift in Sources */,

View File

@ -4,26 +4,19 @@ extension Content {
func generateFeed(for language: ContentLanguage, bookmarkKey: String) {
let posts = posts.map { $0.feedEntry(for: language) }
DispatchQueue.global(qos: .userInitiated).async {
let data = websiteData.localized(in: language)
let navigationItems: [FeedNavigationLink] = websiteData.navigationTags.map {
let localized = $0.localized(in: language)
return .init(text: localized.name, url: localized.urlComponent)
}
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")),
]
DispatchQueue.global(qos: .userInitiated).async {
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"),
title: data.title,
description: data.description,
iconDescription: data.iconDescription,
navigationItems: navigationItems,
posts: posts)
let fileContent = feed.content

View File

@ -5,19 +5,22 @@ import Combine
final class Content: ObservableObject {
@Published
var posts: [Post] = []
var websiteData: WebsiteData
@Published
var pages: [Page] = []
var posts: [Post]
@Published
var tags: [Tag] = []
var pages: [Page]
@Published
var images: [ImageResource] = []
var tags: [Tag]
@Published
var files: [FileResource] = []
var images: [ImageResource]
@Published
var files: [FileResource]
@AppStorage("contentPath")
private var storedContentPath: String = ""
@ -33,12 +36,14 @@ final class Content: ObservableObject {
private var cancellables = Set<AnyCancellable>()
init(posts: [Post] = [],
pages: [Page] = [],
tags: [Tag] = [],
images: [ImageResource] = [],
files: [FileResource] = [],
init(websiteData: WebsiteData,
posts: [Post],
pages: [Page],
tags: [Tag],
images: [ImageResource],
files: [FileResource],
storedContentPath: String) {
self.websiteData = websiteData
self.posts = posts
self.pages = pages
self.tags = tags
@ -59,6 +64,13 @@ final class Content: ObservableObject {
init() {
self.storage = Storage(baseFolder: URL(filePath: ""))
self.websiteData = .mock
self.posts = []
self.pages = []
self.tags = []
self.images = []
self.files = []
contentPath = storedContentPath
do {
try storage.createFolderStructure()
@ -114,9 +126,17 @@ final class Content: ObservableObject {
linkPreviewDescription: page.linkPreviewDescription)
}
private func convert(_ websiteData: LocalizedWebsiteDataFile) -> LocalizedWebsiteData {
.init(title: websiteData.title,
description: websiteData.description,
iconDescription: websiteData.iconDescription)
}
func loadFromDisk() throws {
let storage = Storage(baseFolder: URL(filePath: contentPath))
let websiteData = try storage.loadWebsiteData()
let tagData = try storage.loadAllTags()
let pagesData = try storage.loadAllPages()
let postsData = try storage.loadAllPosts()
@ -170,6 +190,10 @@ final class Content: ObservableObject {
self.files = files.sorted { $0.uniqueId }
self.images = images.values.sorted { $0.id }
self.posts = posts.sorted(ascending: false) { $0.startDate }
self.websiteData = WebsiteData(
navigationTags: websiteData.navigationTags.map { tags[$0]! },
german: convert(websiteData.german),
english: convert(websiteData.english))
}
private func loadPages(_ pagesData: [String : PageFile], tags: [String : Tag]) -> [String : Page] {
@ -202,6 +226,8 @@ final class Content: ObservableObject {
for tag in tags {
storage.save(tagMetadata: tag.tagFile, for: tag.id)
}
storage.save(websiteData: websiteData.dataFile)
// TODO: Remove all files that are no longer in use (they belong to deleted items)
//print("Finished save")
}

View File

@ -80,7 +80,11 @@ extension ImageResource {
print("Failed to create image from \(url.path)")
return failureImage
}
self.size = loadedImage.size
if self.size == .zero && loadedImage.size != .zero {
DispatchQueue.main.async {
self.size = loadedImage.size
}
}
return .init(nsImage: loadedImage)
}

View File

@ -0,0 +1,19 @@
import Foundation
final class LocalizedWebsiteData: ObservableObject {
@Published
var title: String
@Published
var description: String
@Published
var iconDescription: String
init(title: String, description: String, iconDescription: String) {
self.title = title
self.description = description
self.iconDescription = iconDescription
}
}

View File

@ -0,0 +1,20 @@
import Foundation
extension WebsiteData {
var dataFile: WebsiteDataFile {
.init(
navigationTags: navigationTags.map { $0.id },
german: german.dataFile,
english: english.dataFile)
}
}
extension LocalizedWebsiteData {
var dataFile: LocalizedWebsiteDataFile {
.init(title: title,
description: description,
iconDescription: iconDescription)
}
}

View File

@ -1,5 +1,26 @@
import Foundation
final class WebsiteData: ObservableObject {
@Published
var navigationTags: [Tag]
@Published
var german: LocalizedWebsiteData
@Published
var english: LocalizedWebsiteData
init(navigationTags: [Tag] = [], german: LocalizedWebsiteData, english: LocalizedWebsiteData) {
self.navigationTags = navigationTags
self.german = german
self.english = english
}
func localized(in language: ContentLanguage) -> LocalizedWebsiteData {
switch language {
case .english: return english
case .german: return german
}
}
}

View File

@ -2,9 +2,9 @@ import Foundation
struct FeedNavigationLink {
let text: LocalizedText
let text: String
let url: LocalizedText
let url: String
}
struct Feed {
@ -13,11 +13,11 @@ struct Feed {
let language: ContentLanguage
let title: LocalizedText
let title: String
let description: LocalizedText
let description: String
let iconDescription: LocalizedText
let iconDescription: String
let navigationItems: [FeedNavigationLink]
@ -28,8 +28,8 @@ struct Feed {
var result = ""
result += "<!DOCTYPE html><html lang=\"\(language.rawValue)\">"
let head = PageHead(
title: title.getText(for: language),
description: description.getText(for: language))
title: title,
description: description)
result += head.content
result += "<body>"
addNavbar(to: &result)
@ -52,14 +52,14 @@ struct Feed {
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 class=\"nav-animate\" href=\"\(item.url)\">\(item.text)</a>"
}
result += "<a id=\"nav-image\" href=\"/\">"
result += "<img class=\"navbar-icon\" src=\"\(navigationIconPath)\" alt=\"\(iconDescription.getText(for: language))\">"
result += "<img class=\"navbar-icon\" src=\"\(navigationIconPath)\" alt=\"\(iconDescription)\">"
for item in rightNavigationItems {
result += "<a class=\"nav-animate\" href=\"\(item.url.getText(for: language))\">\(item.text.getText(for: language))</a>"
result += "<a class=\"nav-animate\" href=\"\(item.url)\">\(item.text)</a>"
}
result += "</div></nav>" // Close nav-center, navbar
}

View File

@ -15,6 +15,7 @@ extension Content {
private static let dbPath = FileManager.default.documentDirectory.appendingPathComponent("db").path()
static let mock: Content = Content(
websiteData: .mock,
posts: [.empty, .mock, .fullMock],
pages: [.empty],
tags: [.hiking, .mountains, .nature, .sports],

View File

@ -0,0 +1,25 @@
import Foundation
extension WebsiteData {
static let mock: WebsiteData = .init(
german: .german,
english: .english)
}
extension LocalizedWebsiteData {
static var german: LocalizedWebsiteData {
.init(
title: "Titel",
description: "Beschreibung",
iconDescription: "Icon")
}
static var english: LocalizedWebsiteData {
.init(
title: "A Title",
description: "Description",
iconDescription: "Icon")
}
}

View File

@ -242,8 +242,26 @@ final class Storage {
try fileNames(in: videosFolder)
}
// MARK: Website data
private var websiteDataUrl: URL {
baseFolder.appending(path: "website-data.json", directoryHint: .notDirectory)
}
func loadWebsiteData() throws -> WebsiteDataFile {
try read(at: websiteDataUrl)
}
@discardableResult
func save(websiteData: WebsiteDataFile) -> Bool {
write(websiteData, type: "Website Data", id: "-", to: websiteDataUrl)
}
// MARK: Writing files
/**
Encode a value and write it to a file, if the content changed
*/
private func write<T>(_ value: T, type: String, id: String, to file: URL) -> Bool where T: Encodable {
let content: Data
do {
@ -255,6 +273,9 @@ final class Storage {
return write(data: content, type: type, id: id, to: file)
}
/**
Write data to a file if the content changed
*/
private func write(data: Data, type: String, id: String, to file: URL) -> Bool {
if fm.fileExists(atPath: file.path()) {
// Check if content is the same, to prevent unnecessary writes

View File

@ -2,6 +2,8 @@ import Foundation
struct WebsiteDataFile {
let navigationTags: [String]
let german: LocalizedWebsiteDataFile
let english: LocalizedWebsiteDataFile
@ -24,26 +26,3 @@ struct LocalizedWebsiteDataFile {
extension LocalizedWebsiteDataFile: Codable {
}
/*
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)
*/

View File

@ -5,7 +5,8 @@ struct ImageDetailsView: View {
@Environment(\.language)
var language
let image: ImageResource
@ObservedObject
var image: ImageResource
@State
private var newId: String

View File

@ -0,0 +1,17 @@
import SwiftUI
struct ImagesContentView: View {
@ObservedObject
var image: ImageResource
var body: some View {
image.imageToDisplay
.resizable()
.aspectRatio(contentMode: .fit)
}
}
#Preview {
ImagesContentView(image: .init(resourceName: "image1"))
}

View File

@ -19,38 +19,26 @@ struct ImagesView: View {
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) {
NavigationSplitView {
List(content.images, selection: $selectedImage) { image in
Text(image.id)
.tag(image)
}
} content: {
if let selectedImage {
ImagesContentView(image: selectedImage)
.layoutPriority(1)
} else {
Text("Select an image in the sidebar")
}
} detail: {
if let selectedImage {
ImageDetailsView(image: selectedImage)
.frame(maxWidth: 350)
} else {
Text("Select an image to show its details")
EmptyView()
}
}
.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
}
}
}

View File

@ -0,0 +1,25 @@
import SwiftUI
struct LocalizedSettingsView: View {
@ObservedObject
var settings: LocalizedWebsiteData
var body: some View {
VStack(alignment: .leading) {
Text("Title")
.font(.headline)
TextField("", text: $settings.title)
Text("Description")
.font(.headline)
TextField("", text: $settings.description)
Text("Icon description")
.font(.headline)
TextField("", text: $settings.iconDescription)
}
}
}
#Preview {
LocalizedSettingsView(settings: .english)
}

View File

@ -20,31 +20,65 @@ struct SettingsView: View {
@State
private var showFileImporter = false
@State
private var showTagPicker = 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")
ScrollView {
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("Navigation Bar Items")
.font(.headline)
FlowHStack {
ForEach(content.websiteData.navigationTags, id: \.id) { tag in
TagView(tag: .init(
en: tag.english.name,
de: tag.german.name)
)
.foregroundStyle(.white)
}
Button(action: { showTagPicker = true }) {
Image(systemSymbol: .squareAndPencilCircleFill)
.resizable()
.aspectRatio(1, contentMode: .fit)
.frame(height: 22)
.foregroundColor(Color.gray)
.background(Circle()
.fill(Color.white)
.padding(1))
}
.buttonStyle(.plain)
}
LocalizedSettingsView(settings: content.websiteData.localized(in: language))
Text("Feed")
.font(.headline)
Button(action: generateFeed) {
Text("Generate")
}
}
.padding()
}
.padding()
.fileImporter(
isPresented: $showFileImporter,
allowedContentTypes: [.folder],
onCompletion: didSelectContentFolder)
.sheet(isPresented: $showTagPicker) {
TagSelectionView(
presented: $showTagPicker,
selected: $content.websiteData.navigationTags,
tags: $content.tags)
}
}
// MARK: Folder selection
@ -140,4 +174,5 @@ struct SettingsView: View {
#Preview {
SettingsView()
.environmentObject(Content.mock)
}

View File

@ -0,0 +1,68 @@
import SwiftUI
import SFSafeSymbols
private struct PageSelectionView: View {
@ObservedObject
var tag: Tag
@ObservedObject
var page: Page
@Environment(\.language)
private var language
var body: some View {
HStack {
let isSelected = page.tags.contains(tag)
Image(systemSymbol: isSelected ? .checkmarkCircleFill : .circle)
.foregroundStyle(Color.blue)
Text(page.localized(in: language).title)
}
.contentShape(Rectangle())
.onTapGesture {
toggleTagAssignment()
}
}
private func toggleTagAssignment() {
guard let index = page.tags.firstIndex(of: tag) else {
page.tags.append(tag)
return
}
page.tags.remove(at: index)
}
}
struct PageTagAssignmentView: View {
@ObservedObject
var tag: Tag
@Environment(\.language)
private var language
@EnvironmentObject
private var content: Content
@Environment(\.dismiss)
private var dismiss: DismissAction
var body: some View {
VStack {
List {
ForEach(content.pages) { page in
PageSelectionView(tag: tag, page: page)
}
}.frame(minHeight: 400)
Button("Done") {
dismiss()
}.padding(.bottom)
}
}
}
#Preview {
PageTagAssignmentView(tag: .hiking)
.environmentObject(Content.mock)
}

View File

@ -0,0 +1,68 @@
import SwiftUI
import SFSafeSymbols
private struct PostSelectionView: View {
@ObservedObject
var tag: Tag
@ObservedObject
var post: Post
@Environment(\.language)
private var language
var body: some View {
HStack {
let isSelected = post.tags.contains(tag)
Image(systemSymbol: isSelected ? .checkmarkCircleFill : .circle)
.foregroundStyle(Color.blue)
Text(post.localized(in: language).title)
}
.contentShape(Rectangle())
.onTapGesture {
toggleTagAssignment()
}
}
private func toggleTagAssignment() {
guard let index = post.tags.firstIndex(of: tag) else {
post.tags.append(tag)
return
}
post.tags.remove(at: index)
}
}
struct PostTagAssignmentView: View {
@ObservedObject
var tag: Tag
@Environment(\.language)
private var language
@EnvironmentObject
private var content: Content
@Environment(\.dismiss)
private var dismiss: DismissAction
var body: some View {
VStack {
List {
ForEach(content.posts) { post in
PostSelectionView(tag: tag, post: post)
}
}.frame(minHeight: 400)
Button("Done") {
dismiss()
}.padding(.bottom)
}
}
}
#Preview {
PostTagAssignmentView(tag: .hiking)
.environmentObject(Content.mock)
}

View File

@ -0,0 +1,63 @@
import SwiftUI
struct TagContentView: View {
@ObservedObject
var tag: Tag
@Environment(\.language)
private var language
@EnvironmentObject
private var content: Content
@State
private var showPageSelection = false
@State
private var showPostSelection = false
var selectedPages: [Page] {
content.pages.filter { $0.tags.contains(tag) }
}
var selectedPosts: [Post] {
content.posts.filter { $0.tags.contains(tag) }
}
var body: some View {
List {
Section("Pages") {
ForEach(selectedPages) { page in
Text(page.localized(in: language).title)
}
Button(action: { showPageSelection = true }) {
Text("Select pages")
}
.buttonStyle(.plain)
.foregroundStyle(Color.blue)
}
Section("Posts") {
ForEach(selectedPosts) { post in
Text(post.localized(in: language).title)
}
Button(action: { showPostSelection = true }) {
Text("Select posts")
}
.buttonStyle(.plain)
.foregroundStyle(Color.blue)
}
}
.sheet(isPresented: $showPageSelection) {
PageTagAssignmentView(tag: tag)
}
.sheet(isPresented: $showPostSelection) {
PostTagAssignmentView(tag: tag)
}
}
}
#Preview {
TagContentView(tag: .hiking)
.environmentObject(Content.mock)
}

View File

@ -15,49 +15,51 @@ struct TagDetailView: View {
private var showImagePicker = false
var body: some View {
VStack(alignment: .leading) {
Toggle("Appears in overviews", isOn: $tagIsVisible)
.toggleStyle(.switch)
.font(.callout)
.foregroundStyle(.secondary)
ScrollView {
VStack(alignment: .leading) {
Toggle("Appears in overviews", isOn: $tagIsVisible)
.toggleStyle(.switch)
.font(.callout)
.foregroundStyle(.secondary)
Text("Name")
.font(.callout)
.foregroundStyle(.secondary)
TextField("", text: $tag.name)
Text("Name")
.font(.callout)
.foregroundStyle(.secondary)
TextField("", text: $tag.name)
Text("URL String")
.font(.callout)
.foregroundStyle(.secondary)
TextField("", text: $tag.urlComponent)
Text("URL String")
.font(.callout)
.foregroundStyle(.secondary)
TextField("", text: $tag.urlComponent)
Text("Original url")
.font(.callout)
.foregroundStyle(.secondary)
Text(tag.originalUrl ?? "-")
.padding(.top, 1)
.padding(.bottom)
Text("Original url")
.font(.callout)
.foregroundStyle(.secondary)
Text(tag.originalUrl ?? "-")
.padding(.top, 1)
.padding(.bottom)
Text("Subtitle")
.font(.callout)
.foregroundStyle(.secondary)
OptionalTextField("", text: $tag.subtitle)
Text("Subtitle")
.font(.callout)
.foregroundStyle(.secondary)
OptionalTextField("", text: $tag.subtitle)
Text("Description")
.font(.callout)
.foregroundStyle(.secondary)
OptionalTextField("", text: $tag.description)
Text("Description")
.font(.callout)
.foregroundStyle(.secondary)
OptionalTextField("", text: $tag.description)
Text("Thumbnail")
.font(.callout)
.foregroundStyle(.secondary)
Button(action: { showImagePicker = true }) {
Text(tag.thumbnail?.id ?? "Select")
Text("Thumbnail")
.font(.callout)
.foregroundStyle(.secondary)
Button(action: { showImagePicker = true }) {
Text(tag.thumbnail?.id ?? "Select")
}
.buttonStyle(.plain)
.foregroundStyle(.blue)
}
.buttonStyle(.plain)
.foregroundStyle(.blue)
.padding()
}
.padding()
.sheet(isPresented: $showImagePicker) {
ImagePickerView(showImagePicker: $showImagePicker) { image in
tag.thumbnail = image

View File

@ -26,6 +26,7 @@ struct TagsListView: View {
@State
var selectedTag: Tag?
#warning("TODO: Resort tag list when name changes")
var body: some View {
NavigationSplitView {
List(content.tags, selection: $selectedTag) { tag in
@ -33,20 +34,43 @@ struct TagsListView: View {
.tag(tag)
}
} detail: {
} content: {
if let selectedTag {
SelectedTagView(tag: selectedTag)
TagContentView(tag: selectedTag)
.layoutPriority(1)
} else {
Text("Select a tag to show the details")
.font(.largeTitle)
.foregroundColor(.secondary)
}
} detail: {
if let selectedTag {
SelectedTagView(tag: selectedTag)
.frame(maxWidth: 350)
} else {
EmptyView()
}
}
.onAppear {
if selectedTag == nil {
selectedTag = content.tags.first
}
}
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button(action: addNewTag) {
Label("New tag", systemSymbol: .plus)
}
}
}
}
private func addNewTag() {
let newTag = Tag(isVisible: true,
german: .init(urlComponent: "tag", name: "Neuer Tag"),
english: .init(urlComponent: "tag-en", name: "New Tag"))
// Add to top of the list, and resort when changing the name
content.tags.insert(newTag, at: 0)
}
}