Unified detail views, model

This commit is contained in:
Christoph Hagen
2024-12-16 09:54:21 +01:00
parent 1e67a99866
commit 31d1ecb8bd
57 changed files with 853 additions and 954 deletions

View File

@ -5,44 +5,29 @@ struct FileDetailView: View {
@ObservedObject
var file: FileResource
@State
private var newId: String
init(file: FileResource) {
self.file = file
self.newId = file.id
}
private let allowedCharactersInPostId = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-.")).inverted
private var idExists: Bool {
file.content.files.contains { $0.id == newId }
}
private var containsInvalidCharacters: Bool {
newId.rangeOfCharacter(from: allowedCharactersInPostId) != nil
}
var body: some View {
VStack(alignment: .leading) {
Text("File Name")
.font(.headline)
HStack {
TextField("", text: $newId)
.textFieldStyle(.roundedBorder)
Button(action: setNewId) {
Text("Update")
}
.disabled(newId.isEmpty || containsInvalidCharacters || idExists)
}
Text("German Description")
.font(.headline)
TextField("", text: $file.german)
.textFieldStyle(.roundedBorder)
Text("English Description")
.font(.headline)
TextField("", text: $file.english)
.textFieldStyle(.roundedBorder)
DetailTitle(
title: "File",
text: "A file that can be used in a post or page")
IdPropertyView(
id: $file.id,
title: "Name",
footer: "The unique name of the file, which is also used to reference it in posts and pages.",
validation: file.isValid,
update: { file.update(id: $0) })
StringPropertyView(
title: "German Description",
text: $file.german,
footer: "The description for the file in German. Descriptions are used for images and to explain the content of a file.")
StringPropertyView(
title: "English Description",
text: $file.english,
footer: "The description for the file in English. Descriptions are used for images and to explain the content of a file.")
if file.type.isImage {
Text("Image size")
.font(.headline)
@ -53,12 +38,6 @@ struct FileDetailView: View {
Spacer()
}.padding()
}
private func setNewId() {
if !file.update(id: newId) {
newId = file.id
}
}
}
extension FileDetailView: MainContentView {

View File

@ -1,6 +1,6 @@
import SwiftUI
private enum FileFilterType: String, Hashable, CaseIterable, Identifiable {
enum FileFilterType: String, Hashable, CaseIterable, Identifiable {
case images
case text
case videos
@ -38,11 +38,19 @@ struct FileListView: View {
var selectedFile: FileResource?
@State
private var selectedFileType: FileFilterType = .images
private var selectedFileType: FileFilterType
@State
private var searchString = ""
let allowedType: FileFilterType?
init(selectedFile: Binding<FileResource?>, allowedType: FileFilterType? = nil) {
self._selectedFile = selectedFile
self.allowedType = allowedType
self.selectedFileType = allowedType ?? .images
}
var filesBySelectedType: [FileResource] {
content.files.filter { selectedFileType.matches($0.type) }
}
@ -63,6 +71,7 @@ struct FileListView: View {
}
.pickerStyle(.segmented)
.padding(.trailing, 7)
.disabled(allowedType != nil)
TextField("", text: $searchString, prompt: Text("Search"))
.textFieldStyle(.roundedBorder)
.padding(.horizontal, 8)

View File

@ -2,15 +2,18 @@ import SwiftUI
struct FileSelectionView: View {
@Binding
private var selectedFile: FileResource?
@Environment(\.dismiss)
private var dismiss
init(selectedFile: Binding<FileResource?>) {
@Binding
private var selectedFile: FileResource?
let allowedType: FileFilterType?
init(selectedFile: Binding<FileResource?>, allowedType: FileFilterType? = nil) {
self._selectedFile = selectedFile
self.newSelection = selectedFile.wrappedValue
self.allowedType = allowedType
}
@State
@ -18,7 +21,7 @@ struct FileSelectionView: View {
var body: some View {
VStack {
FileListView(selectedFile: $newSelection)
FileListView(selectedFile: $newSelection, allowedType: allowedType)
.frame(minHeight: 500, idealHeight: 600)
HStack {
Button("Cancel") {

View File

@ -0,0 +1,26 @@
import SwiftUI
struct BoolPropertyView: View {
let title: LocalizedStringKey
@Binding
var value: Bool
let footer: LocalizedStringKey
var body: some View {
VStack(alignment: .leading) {
HStack {
Text(title)
.font(.headline)
Spacer()
Toggle("", isOn: $value)
.toggleStyle(.switch)
}
Text(footer)
.foregroundStyle(.secondary)
.padding(.bottom)
}
}
}

View File

@ -0,0 +1,50 @@
import SwiftUI
struct DatePropertyView: View {
let title: String
@Binding
var value: Date
let footer: String
var body: some View {
VStack(alignment: .leading) {
Text(title)
.font(.headline)
DatePicker("", selection: $value, displayedComponents: .date)
.datePickerStyle(.compact)
Text(footer)
.foregroundStyle(.secondary)
.padding(.bottom)
}
}
}
struct OptionalDatePropertyView: View {
let title: LocalizedStringKey
@Binding
var isEnabled: Bool
@Binding
var date: Date
let footer: LocalizedStringKey
var body: some View {
GenericPropertyView(title: title, footer: footer) {
HStack(alignment: .firstTextBaseline) {
Toggle("", isOn: $isEnabled)
.toggleStyle(.switch)
DatePicker("", selection: $date, displayedComponents: .date)
.datePickerStyle(.compact)
.padding(.bottom)
.disabled(!isEnabled)
Spacer()
}
}
}
}

View File

@ -2,9 +2,9 @@ import SwiftUI
struct FilePropertyView: View {
let title: String
let title: LocalizedStringKey
let description: String
let footer: LocalizedStringKey
@Binding
var selectedFile: FileResource?
@ -13,9 +13,7 @@ struct FilePropertyView: View {
private var showFileSelectionSheet = false
var body: some View {
VStack(alignment: .leading) {
Text(title)
.font(.headline)
GenericPropertyView(title: title, footer: footer) {
HStack {
Text(selectedFile?.id ?? "No file selected")
Spacer()
@ -23,9 +21,6 @@ struct FilePropertyView: View {
showFileSelectionSheet = true
}
}
Text(description)
.foregroundStyle(.secondary)
.padding(.bottom)
}
.sheet(isPresented: $showFileSelectionSheet) {
FileSelectionView(selectedFile: $selectedFile)

View File

@ -0,0 +1,57 @@
import SwiftUI
struct FolderOnDiskPropertyView: View {
let title: LocalizedStringKey
@Binding
var folder: String
let footer: LocalizedStringKey
let update: (URL) -> Void
init(title: LocalizedStringKey, folder: Binding<String>, footer: LocalizedStringKey, update: @escaping (URL) -> Void) {
self.title = title
self._folder = folder
self.footer = footer
self.update = update
}
var body: some View {
GenericPropertyView(title: title, footer: footer) {
HStack(alignment: .firstTextBaseline) {
Text(folder)
Spacer()
Button("Select") {
guard let url = openFolderSelectionPanel() else {
return
}
DispatchQueue.main.async {
update(url)
}
}
}
}
}
private func openFolderSelectionPanel() -> 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 directory"
//panel.prompt = "Select Directory"
let response = panel.runModal()
guard response == .OK else {
return nil
}
guard let url = panel.url else {
return nil
}
return url
}
}

View File

@ -0,0 +1,27 @@
import SwiftUI
struct GenericPropertyView<Content>: View where Content: View {
let title: LocalizedStringKey
let footer: LocalizedStringKey
let content: Content
public init(title: LocalizedStringKey, footer: LocalizedStringKey, @ViewBuilder content: () -> Content) {
self.title = title
self.footer = footer
self.content = content()
}
var body: some View {
VStack(alignment: .leading) {
Text(title)
.font(.headline)
content
Text(footer)
.foregroundStyle(.secondary)
.padding(.bottom)
}
}
}

View File

@ -0,0 +1,56 @@
import SwiftUI
struct IdPropertyView: View {
@Binding
var id: String
let title: LocalizedStringKey
let footer: LocalizedStringKey
let validation: (String) -> Bool
let update: (String) -> Void
@State
private var newId: String
init(id: Binding<String>,
title: LocalizedStringKey = "ID",
footer: LocalizedStringKey,
validation: @escaping (String) -> Bool = { _ in true },
update: @escaping (String) -> Void) {
self._id = id
self.title = title
self.footer = footer
self.validation = validation
self.update = update
self.newId = id.wrappedValue
}
private var isValid: Bool {
validation(id)
}
var body: some View {
GenericPropertyView(title: title, footer: footer) {
HStack {
TextField("", text: $newId)
.textFieldStyle(.roundedBorder)
Spacer()
Button("Update", action: setNewId)
.disabled(!isValid)
}
}
}
private func setNewId() {
update(newId)
// In case of failure, resets the id
// In case of update, sets to potentially modified id
DispatchQueue.main.async {
newId = id
}
}
}

View File

@ -2,22 +2,17 @@ import SwiftUI
struct IntegerPropertyView: View {
let title: LocalizedStringKey
@Binding
var value: Int
let title: String
let footer: String
let footer: LocalizedStringKey
var body: some View {
VStack(alignment: .leading) {
Text(title)
.font(.headline)
GenericPropertyView(title: title, footer: footer) {
IntegerField("", number: $value)
.textFieldStyle(.roundedBorder)
Text(footer)
.foregroundStyle(.secondary)
.padding(.bottom)
}
}
}

View File

@ -0,0 +1,36 @@
import SwiftUI
struct OptionalImagePropertyView: View {
let title: LocalizedStringKey
@Binding
var selectedImage: FileResource?
let footer: LocalizedStringKey
@State
private var showSelectionSheet = false
var body: some View {
GenericPropertyView(title: title, footer: footer) {
if let image = selectedImage {
image.imageToDisplay
.resizable()
.aspectRatio(contentMode: .fit)
.frame(maxHeight: 300)
.cornerRadius(8)
}
HStack {
Text(selectedImage?.id ?? "No file selected")
Spacer()
Button("Select") {
showSelectionSheet = true
}
}
}
.sheet(isPresented: $showSelectionSheet) {
FileSelectionView(selectedFile: $selectedImage, allowedType: .images)
}
}
}

View File

@ -0,0 +1,27 @@
import SwiftUI
struct OptionalStringPropertyView: View {
let title: LocalizedStringKey
@Binding
var text: String?
let prompt: String?
let footer: LocalizedStringKey
init(title: LocalizedStringKey, text: Binding<String?>, prompt: String? = nil, footer: LocalizedStringKey) {
self.title = title
self._text = text
self.prompt = prompt
self.footer = footer
}
var body: some View {
GenericPropertyView(title: title, footer: footer) {
OptionalTextField(title, text: $text, prompt: prompt)
.textFieldStyle(.roundedBorder)
}
}
}

View File

@ -0,0 +1,27 @@
import SwiftUI
struct OptionalTextFieldPropertyView: View {
let title: LocalizedStringKey
@Binding
var text: String?
let prompt: String?
let footer: LocalizedStringKey
init(title: LocalizedStringKey, text: Binding<String?>, prompt: String? = nil, footer: LocalizedStringKey) {
self.title = title
self._text = text
self.prompt = prompt
self.footer = footer
}
var body: some View {
GenericPropertyView(title: title, footer: footer) {
OptionalDescriptionField(text: $text)
.textFieldStyle(.roundedBorder)
}
}
}

View File

@ -0,0 +1,30 @@
import SwiftUI
struct PagePropertyView: View {
let title: LocalizedStringKey
@Binding
var selectedPage: Page?
let footer: LocalizedStringKey
@State
private var showPageSelectionSheet = false
var body: some View {
GenericPropertyView(title: title, footer: footer) {
HStack {
Text(selectedPage?.id ?? "No page selected")
Spacer()
Button("Select") {
showPageSelectionSheet = true
}
}
}
.sheet(isPresented: $showPageSelectionSheet) {
PagePickerView(selectedPage: $selectedPage)
}
}
}

View File

@ -0,0 +1,27 @@
import SwiftUI
struct StringPropertyView: View {
let title: LocalizedStringKey
@Binding
var text: String
let prompt: String?
let footer: LocalizedStringKey
init(title: LocalizedStringKey, text: Binding<String>, prompt: String? = nil, footer: LocalizedStringKey) {
self.title = title
self._text = text
self.prompt = prompt
self.footer = footer
}
var body: some View {
GenericPropertyView(title: title, footer: footer) {
TextField(title, text: $text, prompt: prompt.map(Text.init))
.textFieldStyle(.roundedBorder)
}
}
}

View File

@ -3,101 +3,41 @@ import SFSafeSymbols
struct LocalizedPageDetailView: View {
let isExternalPage: Bool
@ObservedObject
private var page: LocalizedPage
init(page: LocalizedPage, showImagePicker: Bool = false) {
self.page = page
self.showImagePicker = showImagePicker
self.newUrlString = page.urlString
}
@State
private var showImagePicker = false
@State
private var newUrlString: String
private let allowedCharactersInPostId = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-")).inverted
private var idExists: Bool {
page.content.pages.contains {
$0.german.urlString == newUrlString
|| $0.english.urlString == newUrlString
}
}
private var containsInvalidCharacters: Bool {
newUrlString.rangeOfCharacter(from: allowedCharactersInPostId) != nil
}
var page: LocalizedPage
var body: some View {
VStack(alignment: .leading) {
HStack {
Text("Page URL String")
.font(.headline)
TextField("", text: $newUrlString)
.textFieldStyle(.roundedBorder)
Button("Update", action: setNewId)
.disabled(newUrlString.isEmpty || containsInvalidCharacters || idExists)
}
.padding(.bottom)
IdPropertyView(
id: $page.urlString,
title: "Page URL String",
footer: "The url component to use for the link to the page",
validation: page.isValid,
update: { page.urlString = $0 })
.disabled(isExternalPage)
Text("Link Preview Title")
.font(.headline)
OptionalTextField("", text: $page.linkPreviewTitle,
prompt: page.title)
.textFieldStyle(.roundedBorder)
.padding(.bottom)
OptionalStringPropertyView(
title: "Preview Title",
text: $page.linkPreviewTitle,
prompt: page.title,
footer: "The title to use for the page when linking to it")
HStack {
Text("Link Preview Image")
.font(.headline)
IconButton(symbol: .squareAndPencilCircleFill,
size: 22,
color: .blue) {
showImagePicker = true
}
OptionalImagePropertyView(
title: "Preview Image",
selectedImage: $page.linkPreviewImage,
footer: "The image to show for previews of this page")
IconButton(symbol: .trashCircleFill,
size: 22,
color: .red) {
page.linkPreviewImage = nil
}.disabled(page.linkPreviewImage == nil)
Spacer()
}
.buttonStyle(.plain)
if let image = page.linkPreviewImage {
image.imageToDisplay
.resizable()
.aspectRatio(contentMode: .fit)
.frame(maxWidth: 400, maxHeight: 300)
.cornerRadius(8)
Text(image.id)
.font(.headline)
}
Text("Link Preview Description")
.font(.headline)
.padding(.top)
OptionalDescriptionField(text: $page.linkPreviewDescription)
.textFieldStyle(.roundedBorder)
.padding(.bottom)
OptionalTextFieldPropertyView(
title: "Preview Description",
text: $page.linkPreviewDescription,
footer: "The description to show in previews of the page")
}
.sheet(isPresented: $showImagePicker) {
ImagePickerView(showImagePicker: $showImagePicker) { image in
page.linkPreviewImage = image
}
}
}
private func setNewId() {
page.urlString = newUrlString
}
}
#Preview {
LocalizedPageDetailView(page: .english)
LocalizedPageDetailView(isExternalPage: false, page: .english)
.environmentObject(Content.mock)
}

View File

@ -15,30 +15,19 @@ struct PageDetailView: View {
@State
private var isGeneratingWebsite = false
@State
private var newId: String
@State
private var didGenerateWebsite: Bool?
init(page: Page) {
self.page = page
self.newId = page.id
}
private let allowedCharactersInPostId = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-")).inverted
private var idExists: Bool {
page.content.pages.contains { $0.id == newId }
}
private var containsInvalidCharacters: Bool {
newId.rangeOfCharacter(from: allowedCharactersInPostId) != nil
}
var body: some View {
ScrollView {
VStack(alignment: .leading) {
DetailTitle(
title: "Page",
text: "A page contains longer content")
HStack {
Button(action: generate) {
Text("Generate")
@ -54,62 +43,40 @@ struct PageDetailView: View {
}
}
}
HStack {
TextField("", text: $newId)
.textFieldStyle(.roundedBorder)
Button("Update", action: setNewId)
.disabled(newId.isEmpty || containsInvalidCharacters || idExists)
}
.padding(.bottom)
IdPropertyView(
id: $page.id,
footer: "The page id is used to link to it internally.",
validation: page.isValid,
update: { page.update(id: $0) })
Text("External url")
.font(.headline)
OptionalTextField("", text: $page.externalLink,
prompt: "External url")
.textFieldStyle(.roundedBorder)
.padding(.bottom)
OptionalStringPropertyView(
title: "External url",
text: $page.externalLink,
footer: "Set an external url to mark this page as external. It will not be generated, and links will be created using the provided url")
HStack {
Text("Draft")
.font(.headline)
Spacer()
Toggle("", isOn: $page.isDraft)
.toggleStyle(.switch)
}
.padding(.bottom)
BoolPropertyView(
title: "Draft",
value: $page.isDraft,
footer: "Indicate a page as a draft to hide it from the website")
.disabled(page.isExternalUrl)
HStack {
Text("Start")
.font(.headline)
Spacer()
DatePicker("", selection: $page.startDate, displayedComponents: .date)
.datePickerStyle(.compact)
.padding(.bottom)
}
DatePropertyView(
title: "Start date",
value: $page.startDate,
footer: "The date when the page content started")
.disabled(page.isExternalUrl)
HStack(alignment: .firstTextBaseline) {
Text("Has end date")
.font(.headline)
Spacer()
Toggle("", isOn: $page.hasEndDate)
.toggleStyle(.switch)
.padding(.bottom)
}
if page.hasEndDate {
HStack(alignment: .firstTextBaseline) {
Text("End date")
.font(.headline)
Spacer()
DatePicker("", selection: $page.endDate, displayedComponents: .date)
.datePickerStyle(.compact)
.padding(.bottom)
}
}
LocalizedPageDetailView(page: page.localized(in: language))
.id(page.id + language.rawValue)
OptionalDatePropertyView(
title: "End date",
isEnabled: $page.hasEndDate,
date: $page.endDate,
footer: "The date when the page content ended")
.disabled(page.isExternalUrl)
LocalizedPageDetailView(
isExternalPage: page.isExternalUrl,
page: page.localized(in: language))
.id(page.id + language.rawValue)
}
.padding()
}
@ -144,14 +111,6 @@ struct PageDetailView: View {
}
}
}
private func setNewId() {
guard page.update(id: newId) else {
newId = page.id
return
}
page.id = newId
}
}
extension PageDetailView: MainContentView {

View File

@ -74,8 +74,8 @@ struct AddPostView: View {
startDate: .now,
endDate: nil,
tags: [],
german: .init(title: "Titel", content: "Text"),
english: .init(title: "Title", content: "Text"))
german: .init(content: content, title: "Titel", text: "Text"),
english: .init(content: content, title: "Title", text: "Text"))
content.posts.insert(post, at: 0)
selectedPost = post
dismissSheet()

View File

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

@ -1,59 +0,0 @@
import SwiftUI
struct ImagePickerView: View {
@Binding
var showImagePicker: Bool
private let selected: (FileResource) -> Void
@EnvironmentObject
private var content: Content
@Environment(\.language)
private var language
init(showImagePicker: Binding<Bool>, selected: @escaping (FileResource) -> Void) {
self._showImagePicker = showImagePicker
self.selected = selected
}
@State
private var selectedImage: FileResource?
var body: some View {
VStack {
Text("Select the image to add")
List(content.images, selection: $selectedImage) { image in
Text("\(image.id)")
.tag(image)
}
.frame(minHeight: 300)
HStack {
Button("Add") {
DispatchQueue.main.async {
if let selectedImage {
print("Added image")
selected(selectedImage)
} else {
print("No image to add")
}
}
showImagePicker = false
}
.disabled(selectedImage == nil)
Button("Cancel", role: .cancel) {
showImagePicker = false
}
}
}
.navigationTitle("Pick an image")
.padding()
}
}
#Preview {
ImagePickerView(showImagePicker: .constant(true)) { _ in
}
.environmentObject(Content.mock)
}

View File

@ -3,64 +3,25 @@ import SwiftUI
struct LocalizedPostDetailView: View {
@ObservedObject
private var item: LocalizedPost
init(post: LocalizedPost, showImagePicker: Bool = false) {
self.item = post
self.showImagePicker = showImagePicker
}
@State
private var showImagePicker = false
var post: LocalizedPost
var body: some View {
VStack(alignment: .leading) {
Text("Link Preview Title")
.font(.headline)
OptionalTextField("", text: $item.linkPreviewTitle,
prompt: item.title)
.textFieldStyle(.roundedBorder)
.padding(.bottom)
OptionalStringPropertyView(
title: "Preview Title",
text: $post.linkPreviewTitle,
prompt: post.title,
footer: "The title to use for the post when linking to it")
HStack {
Text("Link Preview Image")
.font(.headline)
IconButton(symbol: .squareAndPencilCircleFill,
size: 22,
color: .blue) {
showImagePicker = true
}.padding(.bottom)
OptionalImagePropertyView(
title: "Preview Image",
selectedImage: $post.linkPreviewImage,
footer: "The image to show for previews of this post")
IconButton(symbol: .trashCircleFill,
size: 22,
color: .red) {
item.linkPreviewImage = nil
}.disabled(item.linkPreviewImage == nil)
Spacer()
}
.buttonStyle(.plain)
if let image = item.linkPreviewImage {
image.imageToDisplay
.resizable()
.aspectRatio(contentMode: .fit)
.frame(maxWidth: 400, maxHeight: 300)
.cornerRadius(8)
Text(image.id)
.font(.headline)
}
Text("Link Preview Description")
.font(.headline)
.padding(.top)
OptionalDescriptionField(text: $item.linkPreviewDescription)
.textFieldStyle(.roundedBorder)
.padding(.bottom)
}
.sheet(isPresented: $showImagePicker) {
ImagePickerView(showImagePicker: $showImagePicker) { image in
item.linkPreviewImage = image
}
OptionalTextFieldPropertyView(
title: "Preview Description",
text: $post.linkPreviewDescription,
footer: "The description to show in previews of the post")
}
}
}

View File

@ -2,21 +2,21 @@ import SwiftUI
struct PagePickerView: View {
@Binding var showPagePicker: Bool
@Binding var selectedPage: Page?
@EnvironmentObject
private var content: Content
@Environment(\.language)
private var language
@Environment(\.dismiss)
var dismiss
@Binding var selectedPage: Page?
@State
private var newSelection: Page?
init(showPagePicker: Binding<Bool>, selectedPage: Binding<Page?>) {
self._showPagePicker = showPagePicker
init(selectedPage: Binding<Page?>) {
self._selectedPage = selectedPage
self.newSelection = selectedPage.wrappedValue
// TODO: Fix assignment not working
@ -35,17 +35,17 @@ struct PagePickerView: View {
Button("Use selection") {
DispatchQueue.main.async {
self.selectedPage = self.newSelection
dismiss()
}
showPagePicker = false
}
Button("Remove page", role: .destructive) {
DispatchQueue.main.async {
self.selectedPage = nil
dismiss()
}
showPagePicker = false
}
Button("Cancel", role: .cancel) {
showPagePicker = false
dismiss()
}
}
}
@ -55,7 +55,6 @@ struct PagePickerView: View {
}
#Preview {
PagePickerView(showPagePicker: .constant(true),
selectedPage: .constant(nil))
.environmentObject(Content.mock)
PagePickerView(selectedPage: .constant(nil))
.environmentObject(Content.mock)
}

View File

@ -56,7 +56,7 @@ private struct LocalizedContentEditor: View {
}
var body: some View {
TextEditor(text: $post.content)
TextEditor(text: $post.text)
.font(.body)
.frame(minHeight: 150)
.textEditorStyle(.plain)

View File

@ -36,109 +36,51 @@ struct PostDetailView: View {
@ObservedObject
private var post: Post
@State
private var newId: String
@State
private var showLinkedPagePicker = false
init(post: Post) {
self.post = post
self.newId = post.id
}
private let allowedCharactersInPostId = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-")).inverted
private var idExists: Bool {
post.content.posts.contains { $0.id == newId }
}
private var containsInvalidCharacters: Bool {
newId.rangeOfCharacter(from: allowedCharactersInPostId) != nil
}
var body: some View {
ScrollView {
VStack(alignment: .leading) {
Text("ID")
.font(.headline)
HStack {
TextField("", text: $newId)
.textFieldStyle(.roundedBorder)
Button("Update", action: setNewId)
.disabled(newId.isEmpty || containsInvalidCharacters || idExists)
}
.padding(.bottom)
DetailTitle(
title: "Post",
text: "Posts capture quick updates and can link to pages")
HStack {
Text("Draft")
.font(.headline)
Spacer()
Toggle("", isOn: $post.isDraft)
.toggleStyle(.switch)
}
.padding(.bottom)
IdPropertyView(
id: $post.id,
footer: "The id is used to link to post and store them",
validation: post.isValid,
update: { post.update(id: $0) })
HStack {
Text("Start")
.font(.headline)
Spacer()
DatePicker("", selection: $post.startDate, displayedComponents: .date)
.datePickerStyle(.compact)
.padding(.bottom)
}
BoolPropertyView(
title: "Draft",
value: $post.isDraft,
footer: "Indicate a post as a draft to hide it from the website")
HStack(alignment: .firstTextBaseline) {
Text("Has end date")
.font(.headline)
Spacer()
Toggle("", isOn: $post.hasEndDate)
.toggleStyle(.switch)
.padding(.bottom)
}
DatePropertyView(
title: "Start date",
value: $post.startDate,
footer: "The date when the post content started")
if post.hasEndDate {
HStack(alignment: .firstTextBaseline) {
Text("End date")
.font(.headline)
Spacer()
DatePicker("", selection: $post.endDate, displayedComponents: .date)
.datePickerStyle(.compact)
.padding(.bottom)
}
}
HStack {
Text("Linked page")
.font(.headline)
IconButton(symbol: .squareAndPencilCircleFill,
size: 22,
color: .blue) {
showLinkedPagePicker = true
}
Spacer()
}
Text(post.linkedPage?.localized(in: language).title ?? "No page linked")
OptionalDatePropertyView(
title: "End date",
isEnabled: $post.hasEndDate,
date: $post.endDate,
footer: "The date when the post content ended")
PagePropertyView(
title: "Linked page",
selectedPage: $post.linkedPage,
footer: "The page to open when clicking on the post")
LocalizedPostDetailView(post: post.localized(in: language))
}
.padding()
}
.sheet(isPresented: $showLinkedPagePicker) {
PagePickerView(
showPagePicker: $showLinkedPagePicker,
selectedPage: $post.linkedPage)
}
}
private func setNewId() {
guard post.update(id: newId) else {
newId = post.id
return
}
post.id = newId
}
}

View File

@ -50,11 +50,6 @@ struct PostImagesView: View {
.padding()
}
}
.sheet(isPresented: $showImagePicker) {
ImagePickerView(showImagePicker: $showImagePicker) { image in
post.images.append(image)
}
}
}
private func shiftLeft(_ image: FileResource) {

View File

@ -1,60 +0,0 @@
import SwiftUI
struct TextEntrySheet: View {
let title: String
@Binding
var text: String
@Binding
var isValid: Bool
@Environment(\.dismiss)
private var dismiss: DismissAction
var body: some View {
VStack {
Text(title)
.foregroundStyle(.secondary)
TextField("Text", text: $text)
.textFieldStyle(RoundedBorderTextFieldStyle())
.overlay {
if isValid {
EmptyView()
} else {
RoundedRectangle(cornerRadius: 8)
.strokeBorder(lineWidth: 3)
.foregroundStyle(.red)
}
}
.frame(maxWidth: 300)
HStack {
Button(action: submit) {
Text("Submit")
}
.disabled(!isValid)
Button(role: .cancel, action: cancel) {
Text("Cancel")
}
}
}
.padding()
}
private func submit() {
dismiss()
}
private func cancel() {
text = ""
dismiss()
}
}
#Preview {
TextEntrySheet(
title: "Enter the id for the new post",
text: .constant("new"),
isValid: .constant(false))
}

View File

@ -120,9 +120,7 @@ struct PageIssueView: View {
didSelect(page: page)
}
} content: {
PagePickerView(
showPagePicker: $showPagePicker,
selectedPage: $selectedPage)
PagePickerView(selectedPage: $selectedPage)
}
.sheet(isPresented: $showFilePicker) {
if let file = selectedFile {

View File

@ -5,25 +5,18 @@ struct GenerationDetailView: View {
let section: SettingsSection
var body: some View {
Group {
switch section {
//case .generation:
// GenerationSettingsView()
case .folders:
PathSettingsView()
case .navigationBar:
NavigationBarSettingsView()
case .postFeed:
PostFeedSettingsView()
case .pages:
PageSettingsDetailView()
case .tagOverview:
TagOverviewDetailView()
}
switch section {
case .folders:
PathSettingsView()
case .navigationBar:
NavigationBarSettingsView()
case .postFeed:
PostFeedSettingsView()
case .pages:
PageSettingsDetailView()
case .tagOverview:
TagOverviewDetailView()
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.padding()
.navigationTitle("")
}
}

View File

@ -15,12 +15,9 @@ struct NavigationBarSettingsView: View {
var body: some View {
ScrollView {
VStack(alignment: .leading) {
Text("Navigation Bar")
.font(.largeTitle)
.bold()
Text("Customize the navigation bar for all pages at the top of the website")
.foregroundStyle(.secondary)
.padding(.bottom, 30)
DetailTitle(
title: "Navigation Bar",
text: "Customize the navigation bar for all pages at the top of the website")
HStack {
Text("Links")

View File

@ -16,43 +16,43 @@ struct PageSettingsDetailView: View {
text: "Change the way pages are displayed")
IntegerPropertyView(
value: $content.settings.pages.contentWidth,
title: "Content Width",
value: $content.settings.pages.contentWidth,
footer: "The maximum width of the content in pages (in pixels)")
IntegerPropertyView(
value: $content.settings.pages.largeImageWidth,
title: "Fullscreen Image Width",
value: $content.settings.pages.largeImageWidth,
footer: "The maximum width of images that are diplayed fullscreen")
IntegerPropertyView(
value: $content.settings.pages.pageLinkImageSize,
title: "Page Link Image Width",
value: $content.settings.pages.pageLinkImageSize,
footer: "The maximum width of images diplayed as thumbnails on page links")
FilePropertyView(
title: "Default CSS File",
description: "The CSS file containing the styling of all pages",
footer: "The CSS file containing the styling of all pages",
selectedFile: $content.settings.pages.defaultCssFile)
FilePropertyView(
title: "Code Highlighting File",
description: "The JavaScript file to provide syntax highlighting of code blocks",
footer: "The JavaScript file to provide syntax highlighting of code blocks",
selectedFile: $content.settings.pages.codeHighlightingJsFile)
FilePropertyView(
title: "Audio Player CSS File",
description: "The CSS file to provide the style for the audio player",
footer: "The CSS file to provide the style for the audio player",
selectedFile: $content.settings.pages.audioPlayerCssFile)
FilePropertyView(
title: "Audio Player JavaScript File",
description: "The CSS file to provide the functionality for the audio player",
footer: "The CSS file to provide the functionality for the audio player",
selectedFile: $content.settings.pages.audioPlayerJsFile)
FilePropertyView(
title: "3D Model Viewer File",
description: "The JavaScript file to provide the functionality for the 3D model viewer",
footer: "The JavaScript file to provide the functionality for the 3D model viewer",
selectedFile: $content.settings.pages.modelViewerJsFile)
}
}

View File

@ -11,131 +11,66 @@ struct PathSettingsView: View {
@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.")
.foregroundStyle(.secondary)
.padding(.bottom, 30)
DetailTitle(
title: "Folder Settings",
text: "Select the folders for the app to work.")
Text("Content Folder")
.font(.headline)
.padding(.bottom, 1)
Text(contentPath)
Button(action: selectContentFolder) {
Text("Select folder")
}
Text("The folder where the raw content of the website is stored")
.foregroundStyle(.secondary)
.padding(.bottom)
FolderOnDiskPropertyView(
title: "Content Folder",
folder: $contentPath,
footer: "The folder where the raw content of the website is stored") { url in
guard content.storage.save(folderUrl: url, in: .contentPath) else {
return
}
contentPath = url.path()
}
Text("Output Folder")
.font(.headline)
.padding(.bottom, 1)
Text(content.settings.paths.outputDirectoryPath)
Button(action: selectOutputFolder) {
Text("Select folder")
}
Text("The folder where the generated website is stored")
.foregroundStyle(.secondary)
.padding(.bottom)
FolderOnDiskPropertyView(
title: "Output Folder",
folder: $content.settings.paths.outputDirectoryPath,
footer: "The folder where the generated website is stored") { url in
guard content.storage.save(folderUrl: url, in: .outputPath) else {
return
}
content.settings.paths.outputDirectoryPath = url.path()
}
Text("Pages output folder")
.font(.headline)
TextField("", text: $content.settings.paths.pagesOutputFolderPath)
.textFieldStyle(.roundedBorder)
Text("The path in the output folder where the generated pages are stored")
.foregroundStyle(.secondary)
.padding(.bottom)
StringPropertyView(
title: "Pages output folder",
text: $content.settings.paths.pagesOutputFolderPath,
footer: "The path in the output folder where the generated pages are stored")
Text("Tags output folder")
.font(.headline)
TextField("", text: $content.settings.paths.tagsOutputFolderPath)
.textFieldStyle(.roundedBorder)
Text("The path in the output folder where the generated tag pages are stored")
.foregroundStyle(.secondary)
.padding(.bottom)
StringPropertyView(
title: "Tags output folder",
text: $content.settings.paths.tagsOutputFolderPath,
footer: "The path in the output folder where the generated tag pages are stored")
Text("Files output folder")
.font(.headline)
TextField("", text: $content.settings.paths.filesOutputFolderPath)
.textFieldStyle(.roundedBorder)
Text("The path in the output folder where the copied files are stored")
.foregroundStyle(.secondary)
.padding(.bottom)
StringPropertyView(
title: "Files output folder",
text: $content.settings.paths.filesOutputFolderPath,
footer: "The path in the output folder where the copied files are stored")
Text("Images output folder")
.font(.headline)
TextField("", text: $content.settings.paths.imagesOutputFolderPath)
.textFieldStyle(.roundedBorder)
Text("The path in the output folder where the generated images are stored")
.foregroundStyle(.secondary)
.padding(.bottom)
StringPropertyView(
title: "Images output folder",
text: $content.settings.paths.imagesOutputFolderPath,
footer: "The path in the output folder where the generated images are stored")
Text("Videos output folder")
.font(.headline)
TextField("", text: $content.settings.paths.videosOutputFolderPath)
.textFieldStyle(.roundedBorder)
Text("The path in the output folder where the generated videos are stored")
.foregroundStyle(.secondary)
.padding(.bottom)
StringPropertyView(
title: "Videos output folder",
text: $content.settings.paths.videosOutputFolderPath,
footer: "The path in the output folder where the generated videos are stored")
Text("Assets output folder")
.font(.headline)
TextField("", text: $content.settings.paths.assetsOutputFolderPath)
.textFieldStyle(.roundedBorder)
Text("The path in the output folder where assets are stored")
.foregroundStyle(.secondary)
.padding(.bottom)
StringPropertyView(
title: "Assets output folder",
text: $content.settings.paths.assetsOutputFolderPath,
footer: "The path in the output folder where assets are stored")
}
.padding()
}
}
// 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.paths.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 {

View File

@ -15,28 +15,28 @@ struct PostFeedSettingsView: View {
text: "Change the way the posts are displayed")
IntegerPropertyView(
value: $content.settings.posts.contentWidth,
title: "Content Width",
value: $content.settings.posts.contentWidth,
footer: "The maximum width of the content the post feed (in pixels)")
IntegerPropertyView(
value: $content.settings.posts.postsPerPage,
title: "Posts Per Page",
value: $content.settings.posts.postsPerPage,
footer: "The maximum number of posts displayed on a single page")
FilePropertyView(
title: "Default CSS File",
description: "The CSS file containing the styling of all post pages",
footer: "The CSS file containing the styling of all post pages",
selectedFile: $content.settings.posts.defaultCssFile)
FilePropertyView(
title: "Swiper CSS File",
description: "The CSS file containing the styling of image galleries in post feeds",
footer: "The CSS file containing the styling of image galleries in post feeds",
selectedFile: $content.settings.posts.swiperCssFile)
FilePropertyView(
title: "Swiper JavaScript File",
description: "The JavaScript file to load the image gallery code in post feeds",
footer: "The JavaScript file to load the image gallery code in post feeds",
selectedFile: $content.settings.posts.swiperJsFile)
LocalizedPostFeedSettingsView(

View File

@ -11,12 +11,9 @@ struct TagOverviewDetailView: View {
var body: some View {
ScrollView {
VStack(alignment: .leading) {
Text("Tag Overview")
.font(.largeTitle)
.bold()
Text("Configure the page showing all tags")
.foregroundStyle(.secondary)
.padding(.bottom, 30)
DetailTitle(
title: "Tag Overview",
text: "Configure the page showing all tags")
if let page = content.tagOverview?.localized(in: language) {
TagOverviewDetails(page: page)
@ -30,101 +27,48 @@ struct TagOverviewDetailView: View {
private func createTagOverviewPage() {
content.tagOverview = TagOverviewPage(
content: content,
german: .init(title: "Alle Tags", urlString: "alle"),
english: .init(title: "All tags", urlString: "all"))
german: .init(content: content, title: "Alle Tags", urlString: "alle"),
english: .init(content: content, title: "All tags", urlString: "all"))
}
}
private struct TagOverviewDetails: View {
@EnvironmentObject
private var content: Content
@ObservedObject
var page: LocalizedTagOverviewPage
@EnvironmentObject
var content: Content
@State
private var showImagePicker = false
@State
private var newUrlString: String = ""
init(page: LocalizedTagOverviewPage) {
self.page = page
}
private var newUrlCanBeUpdated: Bool {
guard !newUrlString.isEmpty else { return false }
guard content.isValidIdForTagOrPageOrPost(newUrlString) else { return false }
return !content.containsTag(withUrlComponent: newUrlString)
}
var body: some View {
VStack(alignment: .leading) {
Text("Title")
.font(.headline)
TextField("", text: $page.title)
.textFieldStyle(.roundedBorder)
StringPropertyView(
title: "Title",
text: $page.title,
footer: "The title of the overview page")
HStack {
Text("Page URL String")
.font(.headline)
TextField("", text: $newUrlString)
.textFieldStyle(.roundedBorder)
Button("Update", action: setNewId)
.disabled(!newUrlCanBeUpdated)
}
.padding(.bottom)
IdPropertyView(
id: $page.urlComponent,
title: "Page URL String",
footer: "The url component to use for the link to the page",
validation: page.isValid,
update: { page.urlComponent = $0 })
Text("Link Preview Title")
.font(.headline)
OptionalTextField("", text: $page.linkPreviewTitle,
prompt: page.title)
.textFieldStyle(.roundedBorder)
.padding(.bottom)
OptionalStringPropertyView(
title: "Preview Title",
text: $page.linkPreviewTitle,
prompt: page.title,
footer: "The title to use for the page when linking to it")
HStack {
Text("Link Preview Image")
.font(.headline)
IconButton(symbol: .squareAndPencilCircleFill,
size: 22,
color: .blue) {
showImagePicker = true
}
OptionalImagePropertyView(
title: "Preview Image",
selectedImage: $page.linkPreviewImage,
footer: "The image to show for previews of this page")
IconButton(symbol: .trashCircleFill,
size: 22,
color: .red) {
page.linkPreviewImage = nil
}.disabled(page.linkPreviewImage == nil)
}
.buttonStyle(.plain)
if let image = page.linkPreviewImage {
image.imageToDisplay
.resizable()
.aspectRatio(contentMode: .fit)
.frame(maxWidth: 400, maxHeight: 300)
.cornerRadius(8)
Text(image.id)
.font(.headline)
}
Text("Link Preview Description")
.font(.headline)
.padding(.top)
OptionalDescriptionField(text: $page.linkPreviewDescription)
.textFieldStyle(.roundedBorder)
.padding(.bottom)
OptionalTextFieldPropertyView(
title: "Preview Description",
text: $page.linkPreviewDescription,
footer: "The description to show in previews of the page")
}
.sheet(isPresented: $showImagePicker) {
ImagePickerView(showImagePicker: $showImagePicker) { image in
page.linkPreviewImage = image
}
}
}
private func setNewId() {
page.urlString = newUrlString
}
}

View File

@ -28,8 +28,8 @@ struct AddTagView: View {
content: content,
id: "tag",
isVisible: true,
german: .init(urlComponent: "tag", name: "Neuer Tag"),
english: .init(urlComponent: "tag-en", name: "New Tag"))
german: .init(content: .mock, urlComponent: "tag", name: "Neuer Tag"),
english: .init(content: .mock, urlComponent: "tag-en", name: "New Tag"))
// Add to top of the list, and resort when changing the name
content.tags.insert(newTag, at: 0)
dismiss()

View File

@ -1,96 +1,55 @@
import SwiftUI
struct LocalizedTagDetailView: View {
@Binding
var tagIsVisible: Bool
@ObservedObject
var tag: LocalizedTag
@EnvironmentObject
private var content: Content
@State
private var showImagePicker = false
var body: some View {
ScrollView {
VStack(alignment: .leading) {
Toggle("Appears in overviews", isOn: $tagIsVisible)
.toggleStyle(.switch)
.font(.headline)
.padding(.bottom)
Text("Name")
.font(.headline)
TextField("", text: $tag.name)
.textFieldStyle(.roundedBorder)
.padding(.bottom)
Text("URL String")
.font(.headline)
TextField("", text: $tag.urlComponent)
.textFieldStyle(.roundedBorder)
.padding(.bottom)
Text("Original url")
.font(.headline)
Text(tag.originalUrl ?? "-")
.padding(.top, 1)
.padding(.bottom)
Text("Subtitle")
.font(.headline)
OptionalTextField("", text: $tag.subtitle)
.textFieldStyle(.roundedBorder)
.padding(.bottom)
Text("Link Preview Description")
.font(.headline)
.padding(.top)
OptionalDescriptionField(text: $tag.description)
.textFieldStyle(.roundedBorder)
.padding(.bottom)
HStack {
Text("Link Preview Image")
.font(.headline)
IconButton(symbol: .squareAndPencilCircleFill,
size: 22,
color: .blue) {
showImagePicker = true
}
IconButton(symbol: .trashCircleFill,
size: 22,
color: .red) {
tag.linkPreviewImage = nil
}.disabled(tag.linkPreviewImage == nil)
Spacer()
}
.buttonStyle(.plain)
if let image = tag.linkPreviewImage {
image.imageToDisplay
.resizable()
.aspectRatio(contentMode: .fit)
.frame(maxWidth: 400, maxHeight: 300)
.cornerRadius(8)
}
}
.padding()
}
.sheet(isPresented: $showImagePicker) {
ImagePickerView(showImagePicker: $showImagePicker) { image in
tag.linkPreviewImage = image
}
VStack(alignment: .leading) {
StringPropertyView(
title: "Name",
text: $tag.name,
footer: "The displayed name of the tag")
IdPropertyView(
id: $tag.urlComponent,
title: "Page URL String",
footer: "The url component to use in the url for this tag",
validation: tag.isValid,
update: { tag.urlComponent = $0 })
Text("Original url")
.font(.headline)
Text(tag.originalUrl ?? "-")
.foregroundStyle(.secondary)
.padding(.top, 1)
.padding(.bottom)
OptionalStringPropertyView(
title: "Subtitle",
text: $tag.subtitle,
footer: "The subtitle/tagline to use")
OptionalImagePropertyView(
title: "Preview Image",
selectedImage: $tag.linkPreviewImage,
footer: "The image to show for previews of this page")
OptionalTextFieldPropertyView(
title: "Preview Description",
text: $tag.description,
footer: "The description to show in previews of the page")
}
}
}
#Preview {
LocalizedTagDetailView(
tagIsVisible: .constant(true),
tag: Tag.mock.english)
LocalizedTagDetailView(tag: Tag.mock.english)
}

View File

@ -10,9 +10,22 @@ struct TagDetailView: View {
var tag: Tag
var body: some View {
LocalizedTagDetailView(
tagIsVisible: $tag.isVisible,
tag: tag.localized(in: language))
ScrollView {
VStack(alignment: .leading) {
DetailTitle(
title: "Tag",
text: "A tag groups posts and pages together based on a common theme.")
BoolPropertyView(
title: "Appears in overviews",
value: $tag.isVisible,
footer: "Indicate if the tag should appear in the tag list of posts and pages. If the tag is not visible, then it can still be used as a filter.")
LocalizedTagDetailView(
tag: tag.localized(in: language))
}
.padding()
}
}
}