Improve settings, sidebars

This commit is contained in:
Christoph Hagen
2024-12-04 22:54:05 +01:00
parent b3cc4a57db
commit c3309197c0
36 changed files with 968 additions and 426 deletions

View File

@ -40,7 +40,7 @@ struct CHDataManagementApp: App {
FilesView()
}
Tab("Settings", systemImage: SFSymbol.gear.rawValue) {
SettingsView()
SectionedSettingsView()
}
}
.environment(\.language, selectedLanguage)
@ -54,18 +54,16 @@ struct CHDataManagementApp: App {
.tag(ContentLanguage.german)
}.pickerStyle(.segmented)
}
}
.onAppear(perform: importOldContent)
.onReceive(Timer.publish(every: 60.0, on: .main, in: .common).autoconnect()) { _ in
save()
}
.toolbar {
ToolbarItem(placement: .navigation) {
ToolbarItem(placement: .primaryAction) {
Button(action: save) {
Text("Save")
}
}
}
.onAppear(perform: importOldContent)
.onReceive(Timer.publish(every: 60.0, on: .main, in: .common).autoconnect()) { _ in
save()
}
}
}

View File

@ -37,16 +37,18 @@ extension Content {
linkPreviewDescription: page.linkPreviewDescription)
}
private func convert(_ websiteData: LocalizedWebsiteDataFile) -> LocalizedWebsiteData {
.init(title: websiteData.title,
description: websiteData.description,
iconDescription: websiteData.iconDescription)
private func convert(_ settings: LocalizedSettingsFile) -> LocalizedSettings {
.init(navigationBarIconDescription: settings.navigationBarIconDescription,
posts: .init(
title: settings.posts.feedTitle,
description: settings.posts.feedDescription,
feedUrlPrefix: settings.posts.feedUrlPrefix))
}
func loadFromDisk() throws {
let storage = Storage(baseFolder: URL(filePath: contentPath))
let websiteData = try storage.loadWebsiteData()
let settings = try storage.loadSettings()
let tagData = try storage.loadAllTags()
let pagesData = try storage.loadAllPages()
@ -104,10 +106,25 @@ extension Content {
self.images = images.values.sorted { $0.id }
self.videos = videos
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))
self.settings = makeSettings(settings, tags: tags)
}
private func makeSettings(_ settings: SettingsFile, tags: [String : Tag]) -> Settings {
let navigationBar = NavigationBarSettings(
iconPath: settings.navigationBar.navigationIconPath,
tags: settings.navigationBar.navigationTags.map { tags[$0]! })
let posts = PostSettings(
postsPerPage: settings.posts.postsPerPage,
contentWidth: settings.posts.contentWidth)
return Settings(
outputDirectoryPath: settings.outputDirectoryPath,
navigationBar: navigationBar,
posts: posts,
german: convert(settings.german),
english: convert(settings.english))
}
private func loadPages(_ pagesData: [String : PageFile], tags: [String : Tag]) -> [String : Page] {

View File

@ -15,7 +15,7 @@ extension Content {
for tag in tags {
storage.save(tagMetadata: tag.tagFile, for: tag.id)
}
storage.save(websiteData: websiteData.dataFile)
storage.save(settings: settings.file)
do {
try storage.deletePostFiles(notIn: posts.map { $0.id })
@ -113,21 +113,48 @@ private extension LocalizedTag {
}
}
private extension WebsiteData {
private extension NavigationBarSettings {
var dataFile: WebsiteDataFile {
var file: NavigationBarSettingsFile {
.init(navigationIconPath: iconPath,
navigationTags: tags.map { $0.id })
}
}
extension Settings {
var file: SettingsFile {
.init(
navigationTags: navigationTags.map { $0.id },
german: german.dataFile,
english: english.dataFile)
outputDirectoryPath: outputDirectoryPath,
navigationBar: navigationBar.file,
posts: posts.file,
german: german.file,
english: english.file)
}
}
private extension LocalizedWebsiteData {
private extension PostSettings {
var dataFile: LocalizedWebsiteDataFile {
.init(title: title,
description: description,
iconDescription: iconDescription)
var file: PostSettingsFile {
.init(postsPerPage: postsPerPage,
contentWidth: contentWidth)
}
}
private extension LocalizedSettings {
var file: LocalizedSettingsFile {
.init(navigationBarIconDescription: navigationBarIconDescription,
posts: posts.file)
}
}
private extension LocalizedPostSettings {
var file: LocalizedPostSettingsFile {
.init(
feedTitle: title,
feedDescription: description,
feedUrlPrefix: feedUrlPrefix)
}
}

View File

@ -5,7 +5,7 @@ import Combine
final class Content: ObservableObject {
@Published
var websiteData: WebsiteData
var settings: Settings
@Published
var posts: [Post]
@ -39,7 +39,7 @@ final class Content: ObservableObject {
private var cancellables = Set<AnyCancellable>()
init(websiteData: WebsiteData,
init(settings: Settings,
posts: [Post],
pages: [Page],
tags: [Tag],
@ -47,7 +47,7 @@ final class Content: ObservableObject {
files: [FileResource],
videos: [String],
storedContentPath: String) {
self.websiteData = websiteData
self.settings = settings
self.posts = posts
self.pages = pages
self.tags = tags
@ -69,7 +69,7 @@ final class Content: ObservableObject {
init() {
self.storage = Storage(baseFolder: URL(filePath: ""))
self.websiteData = .mock
self.settings = .mock
self.posts = []
self.pages = []
self.tags = []

View File

@ -1,19 +0,0 @@
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,19 @@
import Foundation
final class LocalizedPostSettings: ObservableObject {
@Published
var title: String
@Published
var description: String
@Published
var feedUrlPrefix: String
init(title: String, description: String, feedUrlPrefix: String) {
self.title = title
self.description = description
self.feedUrlPrefix = feedUrlPrefix
}
}

View File

@ -0,0 +1,15 @@
import Foundation
final class LocalizedSettings: ObservableObject {
@Published
var navigationBarIconDescription: String
@Published
var posts: LocalizedPostSettings
init(navigationBarIconDescription: String, posts: LocalizedPostSettings) {
self.navigationBarIconDescription = navigationBarIconDescription
self.posts = posts
}
}

View File

@ -0,0 +1,17 @@
import Foundation
final class NavigationBarSettings: ObservableObject {
/// The path to the main icon in the navigation bar
@Published
var iconPath: String
/// The tags to show in the navigation bar
@Published
var tags: [Tag]
init(iconPath: String, tags: [Tag]) {
self.iconPath = iconPath
self.tags = tags
}
}

View File

@ -0,0 +1,17 @@
import Foundation
final class PostSettings: ObservableObject {
/// The number of posts to show in a single page of the news feed
@Published
var postsPerPage: Int
/// The maximum width of the main content
@Published
var contentWidth: CGFloat
init(postsPerPage: Int, contentWidth: CGFloat) {
self.postsPerPage = postsPerPage
self.contentWidth = contentWidth
}
}

View File

@ -0,0 +1,34 @@
import Foundation
final class Settings: ObservableObject {
@Published
var outputDirectoryPath: String
@Published
var navigationBar: NavigationBarSettings
@Published
var posts: PostSettings
@Published
var german: LocalizedSettings
@Published
var english: LocalizedSettings
init(outputDirectoryPath: String, navigationBar: NavigationBarSettings, posts: PostSettings, german: LocalizedSettings, english: LocalizedSettings) {
self.outputDirectoryPath = outputDirectoryPath
self.navigationBar = navigationBar
self.posts = posts
self.german = german
self.english = english
}
func localized(in language: ContentLanguage) -> LocalizedSettings {
switch language {
case .english: return english
case .german: return german
}
}
}

View File

@ -1,26 +0,0 @@
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

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

View File

@ -1,25 +1,48 @@
import Foundation
extension WebsiteData {
extension Settings {
static let mock: WebsiteData = .init(
static let mock: Settings = .init(
outputDirectoryPath: "/some/path",
navigationBar: .init(iconPath: "/some/other/path", tags: []),
posts: .mock,
german: .german,
english: .english)
}
extension LocalizedWebsiteData {
extension PostSettings {
static var german: LocalizedWebsiteData {
static var mock: PostSettings {
.init(postsPerPage: 20, contentWidth: 600)
}
}
extension LocalizedSettings {
static var german: LocalizedSettings {
.init(navigationBarIconDescription: "Ein Symbol",
posts: .german)
}
static var english: LocalizedSettings {
.init(navigationBarIconDescription: "An icon",
posts: .english)
}
}
extension LocalizedPostSettings {
static var german: LocalizedPostSettings {
.init(
title: "Titel",
description: "Beschreibung",
iconDescription: "Icon")
feedUrlPrefix: "blog")
}
static var english: LocalizedWebsiteData {
static var english: LocalizedPostSettings {
.init(
title: "A Title",
description: "Description",
iconDescription: "Icon")
feedUrlPrefix: "feed")
}
}

View File

@ -1,24 +0,0 @@
import Foundation
extension WebsiteGeneratorConfiguration {
static let english = WebsiteGeneratorConfiguration(
language: .english,
outputDirectory: URL(fileURLWithPath: ""),
postsPerPage: 20,
postFeedTitle: "Posts",
postFeedDescription: "The most recent posts on christophhagen.de",
postFeedUrlPrefix: "feed",
navigationIconPath: "/assets/icons/ch.svg",
mainContentMaximumWidth: 600)
static let german = WebsiteGeneratorConfiguration(
language: .german,
outputDirectory: URL(fileURLWithPath: ""),
postsPerPage: 20,
postFeedTitle: "Beiträge",
postFeedDescription: "Die neusten Beiträge auf christophhagen.de",
postFeedUrlPrefix: "beiträge",
navigationIconPath: "/assets/icons/ch.svg",
mainContentMaximumWidth: 600)
}

View File

@ -49,9 +49,9 @@ final class ImageGenerator {
}
}
func runJobs() -> Bool {
func runJobs(callback: (String) -> Void) -> Bool {
for job in jobs {
print("Generating image \(job.version)")
callback("Generating image \(job.version)")
guard generate(job: job) else {
return false
}
@ -72,6 +72,10 @@ final class ImageGenerator {
func generateVersion(for image: String, type: ImageType, maximumWidth: CGFloat, maximumHeight: CGFloat) -> String {
let version = versionFileName(image: image, type: type, width: maximumWidth, height: maximumHeight)
let fullPath = "/" + relativeImageOutputPath + "/" + version
if exists(version) {
hasNowGenerated(version: version, for: image)
return fullPath
}
if hasPreviouslyGenerated(version: version, for: image), exists(version) {
// Don't add job again
return fullPath
@ -121,6 +125,7 @@ final class ImageGenerator {
print("Missing image \(inputPath.path())")
return false
}
let data: Data
do {
data = try Data(contentsOf: inputPath)
@ -163,8 +168,16 @@ final class ImageGenerator {
return false
}
let result = inOutputImagesFolder { folder in
let url = folder.appendingPathComponent(job.version)
if job.type == .avif {
let out = url.path()
let input = out.replacingOccurrences(of: ".avif", with: ".jpg")
print("avifenc -q 70 \(input) \(out)")
return true
}
do {
try data.write(to: url)
return true
@ -198,19 +211,20 @@ final class ImageGenerator {
private func create(image: NSBitmapImageRep, type: ImageType, quality: CGFloat) -> Data? {
switch type {
case .jpg:
return image.representation(using: .jpeg, properties: [.compressionFactor: NSNumber(value: quality)])
return image.representation(using: .jpeg, properties: [.compressionFactor: NSNumber(value: 0.6)])
case .png:
return image.representation(using: .png, properties: [.compressionFactor: NSNumber(value: quality)])
return image.representation(using: .png, properties: [.compressionFactor: NSNumber(value: 0.6)])
case .avif:
return createAvif(image: image, quality: quality)
return createAvif(image: image, quality: 0.7)
case .webp:
return createWebp(image: image, quality: quality)
return createWebp(image: image, quality: 0.8)
case .gif:
return image.representation(using: .gif, properties: [.compressionFactor: NSNumber(value: quality)])
}
}
private func createAvif(image: NSBitmapImageRep, quality: CGFloat) -> Data? {
return Data()
let newImage = NSImage(size: image.size)
newImage.addRepresentation(image)
return SDImageAVIFCoder.shared.encodedData(with: newImage, format: .AVIF, options: [.encodeCompressionQuality: quality])

View File

@ -0,0 +1,14 @@
struct LocalizedPostSettingsFile {
/// The page title for the post feed
let feedTitle: String
/// The page description for the post feed
let feedDescription: String
/// The path to the feed in the final website, appended with the page number
let feedUrlPrefix: String
}
extension LocalizedPostSettingsFile: Codable { }

View File

@ -0,0 +1,12 @@
struct LocalizedSettingsFile {
let navigationBarIconDescription: String
let posts: LocalizedPostSettingsFile
}
extension LocalizedSettingsFile: Codable {
}

View File

@ -0,0 +1,12 @@
struct NavigationBarSettingsFile {
/// The path to the main icon in the navigation bar
let navigationIconPath: String
/// The tags to show in the navigation bar
let navigationTags: [String]
}
extension NavigationBarSettingsFile: Codable { }

View File

@ -0,0 +1,12 @@
import Foundation
struct PostSettingsFile {
/// The number of posts to show in a single page of the news feed
let postsPerPage: Int
/// The maximum width of the main content
let contentWidth: CGFloat
}
extension PostSettingsFile: Codable { }

View File

@ -0,0 +1,17 @@
import Foundation
struct SettingsFile {
/// The file path to the output directory
let outputDirectoryPath: String
let navigationBar: NavigationBarSettingsFile
let posts: PostSettingsFile
let german: LocalizedSettingsFile
let english: LocalizedSettingsFile
}
extension SettingsFile: Codable { }

View File

@ -1,28 +0,0 @@
import Foundation
struct WebsiteDataFile {
let navigationTags: [String]
let german: LocalizedWebsiteDataFile
let english: LocalizedWebsiteDataFile
}
extension WebsiteDataFile: Codable {
}
struct LocalizedWebsiteDataFile {
let title: String
let description: String
let iconDescription: String
}
extension LocalizedWebsiteDataFile: Codable {
}

View File

@ -236,17 +236,17 @@ final class Storage {
// MARK: Website data
private var websiteDataUrl: URL {
baseFolder.appending(path: "website-data.json", directoryHint: .notDirectory)
private var settingsDataUrl: URL {
baseFolder.appending(path: "settings.json", directoryHint: .notDirectory)
}
func loadWebsiteData() throws -> WebsiteDataFile {
try read(at: websiteDataUrl)
func loadSettings() throws -> SettingsFile {
try read(at: settingsDataUrl)
}
@discardableResult
func save(websiteData: WebsiteDataFile) -> Bool {
write(websiteData, type: "Website Data", id: "-", to: websiteDataUrl)
func save(settings: SettingsFile) -> Bool {
write(settings, type: "Settings", id: "-", to: settingsDataUrl)
}
// MARK: Image generation data

View File

@ -1,71 +1,61 @@
import Foundation
struct WebsiteGeneratorConfiguration {
let language: ContentLanguage
let outputDirectory: URL
let postsPerPage: Int
let postFeedTitle: String
let postFeedDescription: String
let postFeedUrlPrefix: String
let navigationIconPath: String
let mainContentMaximumWidth: CGFloat
}
final class WebsiteGenerator {
let language: ContentLanguage
let outputDirectory: URL
let localizedSettings: LocalizedSettings
let postsPerPage: Int
private var outputDirectory: URL {
URL(filePath: content.settings.outputDirectoryPath)
}
let postFeedTitle: String
private var postsPerPage: Int {
content.settings.posts.postsPerPage
}
let postFeedDescription: String
private var postFeedTitle: String {
localizedSettings.posts.title
}
let postFeedUrlPrefix: String
private var postFeedDescription: String {
localizedSettings.posts.description
}
let navigationIconPath: String
private var postFeedUrlPrefix: String {
localizedSettings.posts.feedUrlPrefix
}
let mainContentMaximumWidth: CGFloat
private var navigationIconPath: String {
content.settings.navigationBar.iconPath
}
private var mainContentMaximumWidth: CGFloat {
content.settings.posts.contentWidth
}
private let content: Content
private let imageGenerator: ImageGenerator
init(content: Content, configuration: WebsiteGeneratorConfiguration) {
self.language = configuration.language
self.outputDirectory = configuration.outputDirectory
self.postsPerPage = configuration.postsPerPage
self.postFeedTitle = configuration.postFeedTitle
self.postFeedDescription = configuration.postFeedDescription
self.postFeedUrlPrefix = configuration.postFeedUrlPrefix
self.navigationIconPath = configuration.navigationIconPath
self.mainContentMaximumWidth = configuration.mainContentMaximumWidth
init(content: Content, language: ContentLanguage) {
self.language = language
self.content = content
self.localizedSettings = content.settings.localized(in: language)
self.imageGenerator = ImageGenerator(
storage: content.storage,
inputImageFolder: content.storage.filesFolder,
relativeImageOutputPath: "images")
}
func generateWebsite() -> Bool {
func generateWebsite(callback: (String) -> Void) -> Bool {
guard imageGenerator.prepareForGeneration() else {
return false
}
guard createPostFeedPages() else {
return false
}
guard imageGenerator.runJobs() else {
guard imageGenerator.runJobs(callback: callback) else {
return false
}
return imageGenerator.save()
@ -77,7 +67,9 @@ final class WebsiteGenerator {
return true
}
let navBarData = createNavigationBarData()
let navBarData = createNavigationBarData(
settings: content.settings.navigationBar,
iconDescription: localizedSettings.navigationBarIconDescription)
let numberOfPages = (totalCount + postsPerPage - 1) / postsPerPage // Round up
for pageIndex in 1...numberOfPages {
@ -91,15 +83,14 @@ final class WebsiteGenerator {
return true
}
private func createNavigationBarData() -> NavigationBarData {
let data = content.websiteData.localized(in: language)
let navigationItems: [NavigationBarLink] = content.websiteData.navigationTags.map {
private func createNavigationBarData(settings: NavigationBarSettings, iconDescription: String) -> NavigationBarData {
let navigationItems: [NavigationBarLink] = settings.tags.map {
let localized = $0.localized(in: language)
return .init(text: localized.name, url: localized.urlComponent)
}
return NavigationBarData(
navigationIconPath: navigationIconPath,
iconDescription: data.iconDescription,
iconDescription: iconDescription,
navigationItems: navigationItems)
}

View File

@ -3,32 +3,108 @@ import SwiftUI
struct PageListView: View {
@Environment(\.language)
var language
private var language
@EnvironmentObject
var content: Content
private var content: Content
@State
var selectedPage: Page?
private var selected: Page?
@State
private var showNewPageView = false
@State
private var newPageId = ""
@State
private var newPageIdIsValid = false
private let allowedCharactersInPageId = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-")).inverted
private var cleanPageId: String {
newPageId.trimmingCharacters(in: .whitespacesAndNewlines)
}
var body: some View {
NavigationSplitView {
List(content.pages, selection: $selectedPage) { page in
List(content.pages, selection: $selected) { page in
Text(page.localized(in: language).title)
.tag(page)
}
} detail: {
// Detail view when an item is selected
if let selectedPage {
PageDetailView(page: selectedPage)
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button(action: { showNewPageView = true }) {
Label("New post", systemSymbol: .plus)
}
}
}
.navigationSplitViewColumnWidth(min: 300, ideal: 300, max: 300)
} content: {
if let selected {
PageDetailView(page: selected)
.layoutPriority(1)
} else {
// Fallback if no item is selected
Text("Select a page to show the content.")
Text("Select a page from the list")
.font(.largeTitle)
.foregroundColor(.secondary)
}
} detail: {
if let selected {
EmptyView()
.frame(maxWidth: 350)
} else {
EmptyView()
.frame(maxWidth: 350)
}
}
.onAppear {
if selected == nil {
selected = content.pages.first
}
}
.sheet(isPresented: $showNewPageView,
onDismiss: addNewPage) {
TextEntrySheet(
title: "Enter the id for the new page",
text: $newPageId,
isValid: $newPageIdIsValid)
}
}
private func isValid(id: String) -> Bool {
let id = cleanPageId
guard id != "" else {
return false
}
guard !content.pages.contains(where: { $0.id == id }) else {
return false
}
// Only allow alphanumeric characters and hyphens
return id.rangeOfCharacter(from: allowedCharactersInPageId) == nil
}
private func addNewPage() {
let id = cleanPageId
guard isValid(id: id) else {
return
}
let page = Page(
id: id,
isDraft: true,
createdDate: .now,
startDate: .now,
endDate: nil,
german: .init(urlString: "seite",
title: "Ein Titel"),
english: .init(urlString: "page",
title: "A Title"),
tags: [])
content.pages.insert(page, at: 0)
selected = page
}
}

View File

@ -8,15 +8,15 @@ struct PostList: View {
@Environment(\.language)
private var language: ContentLanguage
@State
private var newPostId = ""
@State
private var selected: Post? = nil
@State
private var showNewPostView = false
@State
private var newPostId = ""
@State
private var newPostIdIsValid = false
@ -32,7 +32,6 @@ struct PostList: View {
Text(post.localized(in: language).title)
.tag(post)
}
.frame(minWidth: 200)
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button(action: { showNewPostView = true }) {
@ -40,6 +39,7 @@ struct PostList: View {
}
}
}
.navigationSplitViewColumnWidth(min: 250, ideal: 250, max: 250)
} content: {
if let selected {
PostContentView(post: selected)

View File

@ -0,0 +1,91 @@
import SwiftUI
struct FolderSettingsView: View {
@Environment(\.language)
private var language
@AppStorage("contentPath")
private var contentPath: String = ""
@EnvironmentObject
private var content: Content
@State
private var folderSelection: SecurityScopeBookmark = .contentPath
var body: some View {
ScrollView {
VStack(alignment: .leading) {
Text("Folder Settings")
.font(.largeTitle)
.bold()
Text("Select the folders for the app to work.")
.padding(.bottom, 30)
Text("Content Folder")
.font(.headline)
.padding(.bottom, 1)
Text(contentPath)
Button(action: selectContentFolder) {
Text("Select folder")
}
.padding(.bottom)
Text("Output Folder")
.font(.headline)
.padding(.bottom, 1)
Text(content.settings.outputDirectoryPath)
Button(action: selectOutputFolder) {
Text("Select folder")
}
.padding(.bottom)
}
}
}
// MARK: Folder selection
private func selectContentFolder() {
folderSelection = .contentPath
guard let url = savePanelUsingOpenPanel() else {
return
}
self.contentPath = url.path()
}
private func selectOutputFolder() {
folderSelection = .outputPath
guard let url = savePanelUsingOpenPanel() else {
return
}
content.settings.outputDirectoryPath = url.path()
}
private func savePanelUsingOpenPanel() -> URL? {
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 nil
}
guard let url = panel.url else {
return nil
}
content.storage.save(folderUrl: url, in: folderSelection)
return url
}
}
#Preview {
FolderSettingsView()
.environmentObject(Content.mock)
.padding()
}

View File

@ -0,0 +1,76 @@
import SwiftUI
struct GenerationSettingsView: View {
@Environment(\.language)
private var language
@EnvironmentObject
private var content: Content
@State
private var isGeneratingWebsite = false
@State
private var generatorText: String = ""
var body: some View {
ScrollView {
VStack(alignment: .leading) {
Text("Website Generation")
.font(.largeTitle)
.bold()
Text("Regenerate the website and monitor the output")
.padding(.bottom, 30)
HStack {
Button(action: generateFeed) {
Text("Generate")
}
.disabled(isGeneratingWebsite)
if isGeneratingWebsite {
ProgressView()
.progressViewStyle(.circular)
.frame(height: 25)
}
Text(generatorText)
Spacer()
}
}
}
}
private func generateFeed() {
guard content.settings.outputDirectoryPath != "" else {
print("Invalid output path")
return
}
let url = URL(fileURLWithPath: content.settings.outputDirectoryPath)
guard FileManager.default.fileExists(atPath: url.path) else {
print("Missing output folder")
return
}
isGeneratingWebsite = true
DispatchQueue.global(qos: .userInitiated).async {
let generator = WebsiteGenerator(
content: content,
language: language)
_ = generator.generateWebsite { text in
DispatchQueue.main.async {
self.generatorText = text
}
}
DispatchQueue.main.async {
isGeneratingWebsite = false
self.generatorText = "Generation complete"
}
}
}
}
#Preview {
GenerationSettingsView()
.environmentObject(Content.mock)
.padding()
}

View File

@ -0,0 +1,44 @@
import SwiftUI
struct LocalizedPostFeedSettingsView: View {
@ObservedObject
var settings: LocalizedPostSettings
var body: some View {
VStack(alignment: .leading) {
Text("Title")
.font(.headline)
TextField("", text: $settings.title)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: 400)
Text("The title of all post feed pages.")
.padding(.bottom)
Text("URL prefix")
.font(.headline)
TextField("", text: $settings.feedUrlPrefix)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: 400)
Text("The prefix to generate the urls for all post feed pages.")
.padding(.bottom)
Text("Description")
.font(.headline)
TextEditor(text: $settings.description)
.font(.body)
.lineLimit(5, reservesSpace: true)
.frame(maxWidth: 400, minHeight: 50, maxHeight: 500)
.textEditorStyle(.plain)
.padding(.vertical, 8)
.padding(.leading, 3)
.background(Color.gray.opacity(0.1))
.cornerRadius(8)
Text("The description of all post feed pages.")
.padding(.bottom)
}
}
}
#Preview {
LocalizedPostFeedSettingsView(settings: .english)
.padding()
}

View File

@ -1,25 +0,0 @@
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

@ -0,0 +1,84 @@
import SwiftUI
private struct IconDescriptionView: View {
@ObservedObject
var settings: LocalizedSettings
var body: some View {
TextField("", text: $settings.navigationBarIconDescription)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: 300)
}
}
struct NavigationBarSettingsView: View {
@Environment(\.language)
private var language
@EnvironmentObject
private var content: Content
@State
private var showTagPicker = false
var body: some View {
ScrollView {
VStack(alignment: .leading) {
Text("Notification Bar Settings")
.font(.largeTitle)
.bold()
Text("Customize the navigation bar for all pages at the top of the website")
.padding(.bottom, 30)
Text("Icon Path")
.font(.headline)
TextField("", text: $content.settings.navigationBar.iconPath)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: 300)
Text("Specify the path to the icon file with regard to the final website folder.")
.padding(.bottom, 30)
Text("Icon Description")
.font(.headline)
IconDescriptionView(settings: content.settings.localized(in: language))
Text("Provide a description of the icon for screen readers.")
.padding(.bottom, 30)
Text("Visible Tags")
.font(.headline)
FlowHStack {
ForEach(content.settings.navigationBar.tags, 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)
}
Text("Select the tags to show in the navigation bar. The number should be even.")
}
}
.sheet(isPresented: $showTagPicker) {
TagSelectionView(
presented: $showTagPicker,
selected: $content.settings.navigationBar.tags,
tags: $content.tags)
}
}
}
#Preview {
NavigationBarSettingsView()
.environmentObject(Content.mock)
.padding()
}

View File

@ -0,0 +1,30 @@
import SwiftUI
struct PostFeedSettingsView: View {
@Environment(\.language)
private var language
@EnvironmentObject
private var content: Content
var body: some View {
ScrollView {
VStack(alignment: .leading) {
Text("Post Feed Settings")
.font(.largeTitle)
.bold()
Text("Change the way the posts are displayed")
.padding(.bottom, 30)
LocalizedPostFeedSettingsView(settings: content.settings.localized(in: language).posts)
}
}
}
}
#Preview {
PostFeedSettingsView()
.environmentObject(Content.mock)
.padding()
}

View File

@ -0,0 +1,81 @@
import SwiftUI
struct SectionedSettingsView: View {
@State
private var selectedSection: SettingsSection? = .generation
var body: some View {
NavigationSplitView {
SettingsSidebar(selectedSection: $selectedSection)
.frame(minWidth: 200, idealWidth: 200, maxWidth: 200)
} detail: {
DetailView(section: selectedSection)
}
}
}
struct DetailView: View {
let section: SettingsSection?
var body: some View {
Group {
switch section {
case .generation:
GenerationSettingsView()
case .folders:
FolderSettingsView()
case .navigationBar:
NavigationBarSettingsView()
case .postFeed:
PostFeedSettingsView()
case .none:
Text("Select a setting from the sidebar")
.foregroundStyle(.secondary)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.padding()
.navigationTitle(section?.rawValue ?? "")
}
}
struct AppearanceView: View {
var body: some View {
VStack(alignment: .leading) {
Text("Appearance Settings")
.font(.largeTitle)
.bold()
Text("Customize the look and feel of the app.")
}
}
}
struct NotificationsView: View {
var body: some View {
VStack(alignment: .leading) {
Text("Notifications Settings")
.font(.largeTitle)
.bold()
Text("Manage your notification preferences.")
}
}
}
struct PrivacyView: View {
var body: some View {
VStack(alignment: .leading) {
Text("Privacy Settings")
.font(.largeTitle)
.bold()
Text("Configure your privacy and security settings.")
}
}
}
#Preview {
SectionedSettingsView()
}

View File

@ -0,0 +1,34 @@
import SFSafeSymbols
enum SettingsSection: String {
case generation = "Generation"
case folders = "Folders"
case navigationBar = "Navigation Bar"
case postFeed = "Post Feed"
}
extension SettingsSection {
var icon: SFSymbol {
switch self {
case .generation: return .arrowTriangle2Circlepath
case .folders: return .folder
case .navigationBar: return .menubarRectangle
case .postFeed: return .rectangleGrid1x2
}
}
}
extension SettingsSection: CaseIterable {
}
extension SettingsSection: Identifiable {
var id: String { rawValue }
}

View File

@ -0,0 +1,15 @@
import SwiftUI
import SFSafeSymbols
struct SettingsSidebar: View {
@Binding var selectedSection: SettingsSection?
var body: some View {
List(SettingsSection.allCases, selection: $selectedSection) { item in
Label(item.rawValue, systemSymbol: item.icon)
.tag(item)
}
.navigationTitle("Settings")
}
}

View File

@ -1,170 +0,0 @@
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 folderSelection: SecurityScopeBookmark = .contentPath
@State
private var showTagPicker = false
@State
private var isGeneratingWebsite = false
var body: some View {
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)
HStack {
Button(action: generateFeed) {
Text("Generate")
}
.disabled(isGeneratingWebsite)
if isGeneratingWebsite {
ProgressView()
.progressViewStyle(.circular)
.frame(height: 25)
}
Spacer()
}
}
.padding()
}
.sheet(isPresented: $showTagPicker) {
TagSelectionView(
presented: $showTagPicker,
selected: $content.websiteData.navigationTags,
tags: $content.tags)
}
}
// MARK: Folder selection
private func selectContentFolder() {
folderSelection = .contentPath
guard let url = savePanelUsingOpenPanel() else {
return
}
self.contentPath = url.path()
}
private func selectOutputFolder() {
folderSelection = .outputPath
guard let url = savePanelUsingOpenPanel() else {
return
}
self.outputPath = url.path()
}
// 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
}
isGeneratingWebsite = true
DispatchQueue.global(qos: .userInitiated).async {
let generator = WebsiteGenerator(
content: content,
configuration: configuration)
_ = generator.generateWebsite()
DispatchQueue.main.async {
isGeneratingWebsite = false
}
}
}
private var configuration: WebsiteGeneratorConfiguration {
return .init(
language: language,
outputDirectory: URL(filePath: outputPath, directoryHint: .isDirectory),
postsPerPage: 20,
postFeedTitle: "Posts",
postFeedDescription: "The most recent posts on christophhagen.de",
postFeedUrlPrefix: "feed",
navigationIconPath: "/assets/icons/ch.svg",
mainContentMaximumWidth: 600)
}
func savePanelUsingOpenPanel() -> URL? {
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 nil
}
guard let url = panel.url else {
return nil
}
content.storage.save(folderUrl: url, in: folderSelection)
return url
}
}
#Preview {
SettingsView()
.environmentObject(Content.mock)
}