Improve post entry views, add post link data

This commit is contained in:
Christoph Hagen
2024-11-30 08:34:31 +01:00
parent fd1f1f4c40
commit f1e1538167
18 changed files with 567 additions and 101 deletions

View File

@ -19,6 +19,7 @@ struct PageDetailView: View {
VStack(alignment: .leading) {
TextField("", text: page.localized(in: language).editableTitle())
.font(.title)
.textFieldStyle(.plain)
HStack(alignment: .firstTextBaseline) {
Button(action: loadContent) {

View File

@ -5,8 +5,7 @@ struct ImagePickerView: View {
@Binding
var showImagePicker: Bool
@ObservedObject
var post: LocalizedPost
private let selected: (ImageResource) -> Void
@EnvironmentObject
private var content: Content
@ -14,9 +13,9 @@ struct ImagePickerView: View {
@Environment(\.language)
private var language
init(showImagePicker: Binding<Bool>, post: LocalizedPost) {
init(showImagePicker: Binding<Bool>, selected: @escaping (ImageResource) -> Void) {
self._showImagePicker = showImagePicker
self.post = post
self.selected = selected
}
@State
@ -35,7 +34,7 @@ struct ImagePickerView: View {
DispatchQueue.main.async {
if let selectedImage {
print("Added image")
post.images.append(selectedImage)
selected(selectedImage)
} else {
print("No image to add")
}
@ -48,13 +47,13 @@ struct ImagePickerView: View {
}
}
}
.navigationTitle("Pick a page")
.navigationTitle("Pick an image")
.padding()
}
}
#Preview {
ImagePickerView(showImagePicker: .constant(true),
post: LocalizedPost.english)
ImagePickerView(showImagePicker: .constant(true)) { _ in
}
.environmentObject(Content.mock)
}

View File

@ -0,0 +1,123 @@
import SwiftUI
import HighlightedTextEditor
import SFSafeSymbols
struct PostContentView: View {
@ObservedObject
var post: Post
@Environment(\.language)
private var language
var body: some View {
LocalizedPostContentView(post: post)
}
}
private struct LocalizedTitle: View {
@ObservedObject
private var post: LocalizedPost
init(post: LocalizedPost) {
self.post = post
}
var body: some View {
TextField("", text: $post.title)
.font(.system(size: 24, weight: .bold))
.foregroundStyle(Color.primary)
.textFieldStyle(.plain)
.lineLimit(2)
}
}
private struct LocalizedContentEditor: View {
@ObservedObject
private var post: LocalizedPost
init(post: LocalizedPost) {
self.post = post
}
var body: some View {
TextEditor(text: $post.content)
// HighlightedTextEditor(
// text: $post.content,
// highlightRules: .markdown)
}
}
struct LocalizedPostContentView: View {
@ObservedObject
var post: Post
@State
private var showTagPicker = false
@Environment(\.language)
private var language
@EnvironmentObject
private var content: Content
init(post: Post) {
self.post = post
}
var body: some View {
VStack(alignment: .leading) {
Text("Images")
.font(.headline)
PostImagesView(post: post.localized(in: language))
LocalizedTitle(post: post.localized(in: language))
FlowHStack {
ForEach(post.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)
}
LocalizedContentEditor(post: post.localized(in: language))
}
.padding()
.sheet(isPresented: $showTagPicker) {
TagSelectionView(
presented: $showTagPicker,
selected: $post.tags,
tags: $content.tags)
}
}
private func remove(tag: Tag) {
post.tags = post.tags.filter {$0.id != tag.id }
}
}
#Preview(traits: .fixedLayout(width: 450, height: 600)) {
List {
PostContentView(post: .fullMock)
.listRowSeparator(.hidden)
.environment(\.language, ContentLanguage.german)
PostContentView(post: .mock)
.listRowSeparator(.hidden)
}
.environmentObject(Content.mock)
//.listStyle(.plain)
}

View File

@ -0,0 +1,151 @@
import SwiftUI
private struct DetailListItem<Content>: View where Content: View {
private let alignment: VerticalAlignment
private let spacing: CGFloat?
private let content: Content
init(alignment: VerticalAlignment = .center,
spacing: CGFloat? = nil,
@ViewBuilder content: () -> Content) {
self.alignment = alignment
self.spacing = spacing
self.content = content()
}
var body: some View {
HStack(alignment: alignment,
spacing: spacing) {
content
}
.padding(.horizontal)
.padding(.vertical)
.background(Color(NSColor.windowBackgroundColor))
.cornerRadius(8)
}
}
struct PostDetailView: View {
@Environment(\.language)
private var language
@ObservedObject
var post: Post
@State
private var showPagePicker = false
init(post: Post) {
self.post = post
}
private var linkedPageText: String {
if let page = post.linkedPage {
return page.localized(in: language).title
}
return "Add"
}
var body: some View {
ScrollView {
VStack(alignment: .leading) {
Text("Post data")
.font(.headline)
DetailListItem {
Text("ID")
.foregroundStyle(.primary)
TextField("ID", text: $post.id)
.multilineTextAlignment(.trailing)
}
DetailListItem {
Text("Draft")
Spacer()
Toggle(isOn: $post.isDraft) {
Text("")
}.toggleStyle(.switch)
}
DetailListItem {
Text("Start")
Spacer()
DatePicker("", selection: $post.startDate, displayedComponents: .date)
.datePickerStyle(.compact)
}
DetailListItem {
Text("End")
Spacer()
Toggle(isOn: $post.hasEndDate) {
Text("")
}.toggleStyle(.switch)
DatePicker("", selection: $post.endDate, displayedComponents: .date)
.datePickerStyle(.compact)
.disabled(!post.hasEndDate)
}
DetailListItem {
Text("Linked page")
Spacer()
Button(action: { showPagePicker = true }) {
Text(linkedPageText)
}
.buttonStyle(.plain)
.foregroundStyle(.blue)
}
LocalizedPostDetailView(post: post.localized(in: language))
}
.padding()
}
.sheet(isPresented: $showPagePicker) {
PagePickerView(
showPagePicker: $showPagePicker,
selectedPage: $post.linkedPage)
}
}
}
struct LocalizedPostDetailView: View {
@ObservedObject
var post: LocalizedPost
@State
private var showImagePicker = false
var body: some View {
VStack(alignment: .leading) {
Text("Link Preview")
.font(.headline)
DetailListItem {
Text("Title")
Spacer()
OptionalTextField("", text: $post.linkPreviewTitle)
}
DetailListItem {
Text("Image")
Spacer()
Button(action: { showImagePicker = true }) {
Text(post.linkPreviewImage?.id ?? "Select")
}
.buttonStyle(.plain)
.foregroundStyle(.blue)
}
DetailListItem {
Text("Description")
Spacer()
OptionalTextField("", text: $post.linkPreviewDescription)
}
}
.sheet(isPresented: $showImagePicker) {
ImagePickerView(showImagePicker: $showImagePicker) { image in
post.linkPreviewImage = image
}
}
}
}
#Preview(traits: .fixedLayout(width: 270, height: 500)) {
PostDetailView(post: .fullMock)
}

View File

@ -1,7 +1,7 @@
import SwiftUI
import SFSafeSymbols
private struct NavigationIcon: View {
struct NavigationIcon: View {
let symbol: SFSymbol
@ -96,10 +96,9 @@ struct PostImageGalleryView: View {
}
}
.sheet(isPresented: $showImagePicker) {
ImagePickerView(
showImagePicker: $showImagePicker,
post: post
)
ImagePickerView(showImagePicker: $showImagePicker) { image in
post.images.append(image)
}
}
}

View File

@ -0,0 +1,94 @@
import SwiftUI
struct PostImagesView: View {
@ObservedObject
var post: LocalizedPost
@State
private var showImagePicker = false
var body: some View {
ScrollView(.horizontal) {
HStack(alignment: .center, spacing: 8) {
ForEach(post.images) { image in
ZStack {
image.imageToDisplay
.resizable()
.aspectRatio(contentMode: .fill)
.frame(maxWidth: 300, maxHeight: 200)
.cornerRadius(8)
.layoutPriority(1)
VStack {
HStack {
Button(action: { remove(image) }) {
NavigationIcon(symbol: .trash, edge: .all)
}
.buttonStyle(.plain)
Spacer()
}
Spacer()
HStack {
Button(action: { shiftLeft(image) }) {
NavigationIcon(symbol: .chevronLeft, edge: .trailing)
}
.buttonStyle(.plain)
Spacer()
Button(action: { shiftRight(image) }) {
NavigationIcon(symbol: .chevronRight, edge: .leading)
}
.buttonStyle(.plain)
}
}
.padding()
}
}
Button(action: { showImagePicker = true }) {
NavigationIcon(symbol: .plus, edge: .all)
}
.buttonStyle(.plain)
.padding()
}
}
.sheet(isPresented: $showImagePicker) {
ImagePickerView(showImagePicker: $showImagePicker) { image in
post.images.append(image)
}
}
}
private func shiftLeft(_ image: ImageResource) {
guard let index = post.images.firstIndex(of: image) else {
return
}
guard index > 0 else {
return
}
post.images.swapAt(index, index - 1)
}
private func shiftRight(_ image: ImageResource) {
guard let index = post.images.firstIndex(of: image) else {
return
}
guard index < post.images.count - 1 else {
return
}
post.images.swapAt(index, index + 1)
}
private func remove(_ image: ImageResource) {
guard let index = post.images.firstIndex(of: image) else {
return
}
post.images.remove(at: index)
}
}
#Preview {
VStack(alignment: .leading) {
Text("Images")
.font(.headline)
PostImagesView(post: .english)
}
}

View File

@ -5,51 +5,96 @@ struct PostList: View {
@EnvironmentObject
private var content: Content
@State
private var showNewPostIdSheet = false
@Environment(\.language)
private var language: ContentLanguage
@State
private var newPostId = ""
@State
private var selected: Post? = nil
@State
private var showNewPostView = false
@State
private var newPostIdIsValid = false
private let allowedCharactersInPostId = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-")).inverted
private var cleanPostId: String {
newPostId.trimmingCharacters(in: .whitespacesAndNewlines)
}
var body: some View {
List {
if content.posts.isEmpty {
HorizontalCenter {
Text("No posts yet.")
.padding()
}
.listRowSeparator(.hidden)
NavigationSplitView {
List(content.posts, selection: $selected) { post in
Text(post.localized(in: language).title)
.tag(post)
}
HorizontalCenter {
Button(action: { showNewPostIdSheet = true }) {
Text("Add post")
.frame(minWidth: 200)
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button(action: { showNewPostView = true }) {
Label("New post", systemSymbol: .plus)
}
}
.padding()
.listRowSeparator(.hidden)
}
ForEach(content.posts) { post in
HorizontalCenter {
PostView(post: post)
.frame(maxWidth: 600)
}
.listRowSeparator(.hidden)
.listRowInsets(.init(top: 0, leading: 0, bottom: 30, trailing: 0))
} content: {
if let selected {
PostContentView(post: selected)
.layoutPriority(1)
} else {
HStack {
Spacer()
Text("Select a post to show the content")
.font(.largeTitle)
.foregroundColor(.secondary)
Spacer()
}.layoutPriority(1)
}
} detail: {
if let selected {
PostDetailView(post: selected)
.frame(minWidth: 280)
} else {
Text("No post selected")
.frame(minWidth: 280)
}
}
.listStyle(.plain)
.sheet(isPresented: $showNewPostIdSheet, onDismiss: addNewPost) {
TextEntrySheet(title: "Enter the new post id", text: $newPostId)
.sheet(isPresented: $showNewPostView,
onDismiss: addNewPost) {
TextEntrySheet(
title: "Enter the id for the new post",
text: $newPostId,
isValid: $newPostIdIsValid)
}
.onChange(of: newPostId) { _, newValue in
newPostIdIsValid = isValid(id: newValue)
}
.onAppear {
if selected == nil {
selected = content.posts.first
}
}
}
private func addNewPost() {
let id = newPostId.trimmingCharacters(in: .whitespacesAndNewlines)
private func isValid(id: String) -> Bool {
let id = cleanPostId
guard id != "" else {
return
return false
}
guard !content.posts.contains(where: { $0.id == id }) else {
print("ID \(id) already exists")
return false
}
// Only allow alphanumeric characters and hyphens
return id.rangeOfCharacter(from: allowedCharactersInPostId) == nil
}
private func addNewPost() {
let id = cleanPostId
guard isValid(id: id) else {
return
}
@ -63,6 +108,7 @@ struct PostList: View {
german: .init(title: "Titel", content: "Text"),
english: .init(title: "Title", content: "Text"))
content.posts.insert(post, at: 0)
selected = post
}
}

View File

@ -26,9 +26,6 @@ struct LocalizedPostView: View {
@State
private var showDatePicker = false
@State
private var showPagePicker = false
@State
private var showImagePicker = false
@ -41,13 +38,6 @@ struct LocalizedPostView: View {
@EnvironmentObject
private var content: Content
private var linkedPageText: String {
if let page = post.linkedPage {
return page.localized(in: language).title
}
return "Add linked page"
}
var body: some View {
VStack(alignment: .center) {
if localized.images.isEmpty {
@ -62,14 +52,9 @@ struct LocalizedPostView: View {
.aspectRatio(1.33, contentMode: .fill)
}
VStack(alignment: .leading) {
HStack(alignment: .center, spacing: 0) {
Text(post.dateText(in: language))
.font(.system(size: 19, weight: .semibold))
.onTapGesture { showDatePicker = true }
Spacer()
Toggle("Draft", isOn: $post.isDraft)
}
.foregroundStyle(.secondary)
Text(post.dateText(in: language))
.font(.system(size: 19, weight: .semibold))
.foregroundStyle(.secondary)
TextField("", text: $localized.title)
.font(.system(size: 24, weight: .bold))
.foregroundStyle(Color.primary)
@ -101,13 +86,6 @@ struct LocalizedPostView: View {
.textEditorStyle(.plain)
.padding(.leading, -5)
.scrollDisabled(true)
HorizontalCenter {
Button(action: { showPagePicker = true }) {
Text(linkedPageText)
}
.buttonStyle(.plain)
.foregroundStyle(ColorPalette.link)
}
}
.padding()
}
@ -118,15 +96,10 @@ struct LocalizedPostView: View {
post: post,
showDatePicker: $showDatePicker)
}
.sheet(isPresented: $showPagePicker) {
PagePickerView(
showPagePicker: $showPagePicker,
selectedPage: $post.linkedPage)
}
.sheet(isPresented: $showImagePicker) {
ImagePickerView(
showImagePicker: $showImagePicker,
post: localized)
ImagePickerView(showImagePicker: $showImagePicker) { image in
localized.images.append(image)
}
}
.sheet(isPresented: $showTagPicker) {
TagSelectionView(

View File

@ -7,21 +7,33 @@ struct TextEntrySheet: View {
@Binding
var text: String
@Environment(\.dismiss)
var dismiss: DismissAction
@Binding
var isValid: Bool
@State
private var enteredText: String = ""
@Environment(\.dismiss)
private var dismiss: DismissAction
var body: some View {
VStack {
Text(title)
.foregroundStyle(.secondary)
TextField("Text", text: $enteredText)
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")
}
@ -31,15 +43,18 @@ struct TextEntrySheet: View {
}
private func submit() {
text = enteredText
dismiss()
}
private func cancel() {
text = ""
dismiss()
}
}
#Preview {
TextEntrySheet(title: "Enter the id for the new post", text: .constant("new"))
TextEntrySheet(
title: "Enter the id for the new post",
text: .constant("new"),
isValid: .constant(false))
}