Consolidate images and files
This commit is contained in:
91
CHDataManagement/Views/Posts/AddPostView.swift
Normal file
91
CHDataManagement/Views/Posts/AddPostView.swift
Normal file
@ -0,0 +1,91 @@
|
||||
import SwiftUI
|
||||
|
||||
struct AddPostView: View {
|
||||
|
||||
@Environment(\.dismiss)
|
||||
private var dismiss: DismissAction
|
||||
|
||||
@Environment(\.language)
|
||||
private var language: ContentLanguage
|
||||
|
||||
@EnvironmentObject
|
||||
private var content: Content
|
||||
|
||||
@Binding
|
||||
var selectedPost: Post?
|
||||
|
||||
@State
|
||||
private var newPostId = ""
|
||||
|
||||
private let allowedCharactersInPostId = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-")).inverted
|
||||
|
||||
init(selected: Binding<Post?>) {
|
||||
self._selectedPost = selected
|
||||
}
|
||||
|
||||
private var idExists: Bool {
|
||||
content.posts.contains { $0.id == newPostId }
|
||||
}
|
||||
|
||||
private var containsInvalidCharacters: Bool {
|
||||
newPostId.rangeOfCharacter(from: allowedCharactersInPostId) != nil
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
Text("New post")
|
||||
.font(.headline)
|
||||
|
||||
TextField("", text: $newPostId)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: 350)
|
||||
if newPostId.isEmpty {
|
||||
Text("Enter the id of the new post to create")
|
||||
.foregroundStyle(.secondary)
|
||||
} else if idExists {
|
||||
Text("A post with the same id already exists")
|
||||
.foregroundStyle(Color.red)
|
||||
} else if containsInvalidCharacters {
|
||||
Text("The id contains invalid characters")
|
||||
.foregroundStyle(Color.red)
|
||||
} else {
|
||||
Text("Create a new post with the id")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
HStack {
|
||||
Button(role: .cancel, action: dismissSheet) {
|
||||
Text("Cancel")
|
||||
}
|
||||
Button(action: addNewPost) {
|
||||
Text("Create")
|
||||
}
|
||||
.disabled(newPostId.isEmpty || containsInvalidCharacters || idExists)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
|
||||
private func addNewPost() {
|
||||
let post = Post(
|
||||
id: newPostId,
|
||||
isDraft: true,
|
||||
createdDate: .now,
|
||||
startDate: .now,
|
||||
endDate: nil,
|
||||
tags: [],
|
||||
german: .init(title: "Titel", content: "Text"),
|
||||
english: .init(title: "Title", content: "Text"))
|
||||
content.posts.insert(post, at: 0)
|
||||
selectedPost = post
|
||||
dismissSheet()
|
||||
}
|
||||
|
||||
private func dismissSheet() {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
AddPostView(selected: .constant(nil))
|
||||
.environmentObject(Content.mock)
|
||||
}
|
@ -5,7 +5,7 @@ struct ImagePickerView: View {
|
||||
@Binding
|
||||
var showImagePicker: Bool
|
||||
|
||||
private let selected: (ImageResource) -> Void
|
||||
private let selected: (FileResource) -> Void
|
||||
|
||||
@EnvironmentObject
|
||||
private var content: Content
|
||||
@ -13,13 +13,13 @@ struct ImagePickerView: View {
|
||||
@Environment(\.language)
|
||||
private var language
|
||||
|
||||
init(showImagePicker: Binding<Bool>, selected: @escaping (ImageResource) -> Void) {
|
||||
init(showImagePicker: Binding<Bool>, selected: @escaping (FileResource) -> Void) {
|
||||
self._showImagePicker = showImagePicker
|
||||
self.selected = selected
|
||||
}
|
||||
|
||||
@State
|
||||
private var selectedImage: ImageResource?
|
||||
private var selectedImage: FileResource?
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
|
@ -35,7 +35,7 @@ struct LocalizedPostDetailView: View {
|
||||
size: 22,
|
||||
color: .red) {
|
||||
item.linkPreviewImage = nil
|
||||
}
|
||||
}.disabled(item.linkPreviewImage == nil)
|
||||
Spacer()
|
||||
}
|
||||
|
||||
|
@ -10,11 +10,24 @@ struct PostContentView: View {
|
||||
@Environment(\.language)
|
||||
private var language
|
||||
|
||||
init(post: Post) {
|
||||
self.post = post
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
LocalizedPostContentView(post: post)
|
||||
}
|
||||
}
|
||||
|
||||
extension PostContentView: MainContentView {
|
||||
|
||||
init(item: Post) {
|
||||
self.post = item
|
||||
}
|
||||
|
||||
static let itemDescription = "a post"
|
||||
}
|
||||
|
||||
private struct LocalizedTitle: View {
|
||||
|
||||
@ObservedObject
|
||||
@ -44,9 +57,13 @@ private struct LocalizedContentEditor: View {
|
||||
|
||||
var body: some View {
|
||||
TextEditor(text: $post.content)
|
||||
// HighlightedTextEditor(
|
||||
// text: $post.content,
|
||||
// highlightRules: .markdown)
|
||||
.font(.body)
|
||||
.frame(minHeight: 150)
|
||||
.textEditorStyle(.plain)
|
||||
.padding(.vertical, 8)
|
||||
.padding(.leading, 3)
|
||||
.background(Color.gray.opacity(0.1))
|
||||
.cornerRadius(8)
|
||||
}
|
||||
}
|
||||
|
||||
@ -76,10 +93,7 @@ struct LocalizedPostContentView: View {
|
||||
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)
|
||||
)
|
||||
TagView(text: tag.localized(in: language).name)
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
Button(action: { showTagPicker = true }) {
|
||||
|
@ -95,6 +95,16 @@ struct PostDetailView: View {
|
||||
}
|
||||
}
|
||||
|
||||
extension PostDetailView: MainContentView {
|
||||
|
||||
init(item: Post) {
|
||||
self.item = item
|
||||
}
|
||||
|
||||
static let itemDescription = "a post"
|
||||
}
|
||||
|
||||
|
||||
#Preview(traits: .fixedLayout(width: 270, height: 500)) {
|
||||
PostDetailView(post: .fullMock)
|
||||
}
|
||||
|
@ -1,147 +0,0 @@
|
||||
import SwiftUI
|
||||
import SFSafeSymbols
|
||||
|
||||
struct NavigationIcon: View {
|
||||
|
||||
let symbol: SFSymbol
|
||||
|
||||
let edge: Edge.Set
|
||||
|
||||
var body: some View {
|
||||
SwiftUI.Image(systemSymbol: symbol)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.padding(5)
|
||||
.padding(edge, 2)
|
||||
.fontWeight(.light)
|
||||
.foregroundStyle(Color.white.opacity(0.8))
|
||||
.frame(width: 30, height: 30)
|
||||
.background(Color.black.opacity(0.7).clipShape(Circle()))
|
||||
}
|
||||
}
|
||||
|
||||
struct PostImageGalleryView: View {
|
||||
|
||||
@ObservedObject
|
||||
var post: LocalizedPost
|
||||
|
||||
@State private var currentIndex = 0
|
||||
|
||||
@State
|
||||
private var showImagePicker = false
|
||||
|
||||
private var imageAtCurrentIndex: Image? {
|
||||
guard !post.images.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
guard currentIndex < post.images.count else {
|
||||
return post.images.last?.imageToDisplay
|
||||
}
|
||||
return post.images[currentIndex].imageToDisplay
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .center) {
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
ZStack(alignment: .bottom) {
|
||||
if let imageAtCurrentIndex {
|
||||
imageAtCurrentIndex
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
}
|
||||
if post.images.count > 1 {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(0..<post.images.count, id: \.self) { index in
|
||||
Circle()
|
||||
.fill(index == currentIndex ? Color.white : Color.gray)
|
||||
.frame(width: 10, height: 10)
|
||||
}
|
||||
}
|
||||
.padding(.bottom, 10)
|
||||
}
|
||||
}
|
||||
HStack(spacing: 5) {
|
||||
Button(action: shiftBack) {
|
||||
NavigationIcon(symbol: .arrowTurnUpLeft, edge: .trailing)
|
||||
}
|
||||
Button(action: shiftForward) {
|
||||
NavigationIcon(symbol: .arrowTurnUpRight, edge: .leading)
|
||||
}
|
||||
Spacer()
|
||||
Button(action: { showImagePicker = true }) {
|
||||
NavigationIcon(symbol: .plus, edge: .all)
|
||||
}
|
||||
Button(action: removeImage) {
|
||||
NavigationIcon(symbol: .trash, edge: .all)
|
||||
}
|
||||
}.padding()
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(.blue)
|
||||
if post.images.count > 1 {
|
||||
HStack {
|
||||
Button(action: previous) {
|
||||
NavigationIcon(symbol: .chevronLeft, edge: .trailing)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding()
|
||||
|
||||
Spacer()
|
||||
Button(action: next) {
|
||||
NavigationIcon(symbol: .chevronRight, edge: .leading)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showImagePicker) {
|
||||
ImagePickerView(showImagePicker: $showImagePicker) { image in
|
||||
post.images.append(image)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func previous() {
|
||||
if currentIndex > 0 {
|
||||
currentIndex -= 1
|
||||
} else {
|
||||
currentIndex = post.images.count - 1
|
||||
}
|
||||
}
|
||||
|
||||
private func next() {
|
||||
if currentIndex < post.images.count - 1 {
|
||||
currentIndex += 1
|
||||
} else {
|
||||
currentIndex = 0
|
||||
}
|
||||
}
|
||||
|
||||
private func shiftBack() {
|
||||
guard currentIndex > 0 else {
|
||||
return
|
||||
}
|
||||
post.images.swapAt(currentIndex, currentIndex-1)
|
||||
currentIndex -= 1
|
||||
}
|
||||
|
||||
private func shiftForward() {
|
||||
guard currentIndex < post.images.count - 1 else {
|
||||
return
|
||||
}
|
||||
post.images.swapAt(currentIndex, currentIndex+1)
|
||||
currentIndex += 1
|
||||
}
|
||||
|
||||
private func removeImage() {
|
||||
post.images.remove(at: currentIndex)
|
||||
if currentIndex >= post.images.count {
|
||||
currentIndex = post.images.count - 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview(traits: .fixedLayout(width: 300, height: 250)) {
|
||||
PostImageGalleryView(post: .german)
|
||||
}
|
@ -57,7 +57,7 @@ struct PostImagesView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func shiftLeft(_ image: ImageResource) {
|
||||
private func shiftLeft(_ image: FileResource) {
|
||||
guard let index = post.images.firstIndex(of: image) else {
|
||||
return
|
||||
}
|
||||
@ -67,7 +67,7 @@ struct PostImagesView: View {
|
||||
post.images.swapAt(index, index - 1)
|
||||
}
|
||||
|
||||
private func shiftRight(_ image: ImageResource) {
|
||||
private func shiftRight(_ image: FileResource) {
|
||||
guard let index = post.images.firstIndex(of: image) else {
|
||||
return
|
||||
}
|
||||
@ -77,7 +77,7 @@ struct PostImagesView: View {
|
||||
post.images.swapAt(index, index + 1)
|
||||
}
|
||||
|
||||
private func remove(_ image: ImageResource) {
|
||||
private func remove(_ image: FileResource) {
|
||||
guard let index = post.images.firstIndex(of: image) else {
|
||||
return
|
||||
}
|
||||
|
@ -1,118 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct PostList: View {
|
||||
|
||||
@EnvironmentObject
|
||||
private var content: Content
|
||||
|
||||
@Environment(\.language)
|
||||
private var language: ContentLanguage
|
||||
|
||||
@State
|
||||
private var selected: Post? = nil
|
||||
|
||||
@State
|
||||
private var showNewPostView = false
|
||||
|
||||
@State
|
||||
private var newPostId = ""
|
||||
|
||||
@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 {
|
||||
NavigationSplitView {
|
||||
List(content.posts, selection: $selected) { post in
|
||||
Text(post.localized(in: language).title)
|
||||
.tag(post)
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button(action: { showNewPostView = true }) {
|
||||
Label("New post", systemSymbol: .plus)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationSplitViewColumnWidth(min: 250, ideal: 250, max: 250)
|
||||
} 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)
|
||||
}
|
||||
}
|
||||
.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 isValid(id: String) -> Bool {
|
||||
let id = cleanPostId
|
||||
guard id != "" else {
|
||||
return false
|
||||
}
|
||||
|
||||
guard !content.posts.contains(where: { $0.id == id }) else {
|
||||
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
|
||||
}
|
||||
|
||||
let post = Post(
|
||||
id: id,
|
||||
isDraft: true,
|
||||
createdDate: .now,
|
||||
startDate: .now,
|
||||
endDate: nil,
|
||||
tags: [],
|
||||
german: .init(title: "Titel", content: "Text"),
|
||||
english: .init(title: "Title", content: "Text"))
|
||||
content.posts.insert(post, at: 0)
|
||||
selected = post
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
PostList()
|
||||
.environmentObject(Content())
|
||||
}
|
42
CHDataManagement/Views/Posts/PostListView.swift
Normal file
42
CHDataManagement/Views/Posts/PostListView.swift
Normal file
@ -0,0 +1,42 @@
|
||||
import SwiftUI
|
||||
|
||||
struct PostListView: View {
|
||||
|
||||
@Environment(\.language)
|
||||
private var language
|
||||
|
||||
@EnvironmentObject
|
||||
private var content: Content
|
||||
|
||||
@Binding
|
||||
private var selectedPost: Post?
|
||||
|
||||
@State
|
||||
private var searchString = ""
|
||||
|
||||
init(selectedPost: Binding<Post?>) {
|
||||
self._selectedPost = selectedPost
|
||||
}
|
||||
|
||||
private var filteredPosts: [Post] {
|
||||
guard !searchString.isEmpty else {
|
||||
return content.posts
|
||||
}
|
||||
return content.posts.filter { $0.localized(in: language).title.contains(searchString) }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
TextField("", text: $searchString, prompt: Text("Search"))
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.padding(.horizontal, 8)
|
||||
List(filteredPosts, selection: $selectedPost) { post in
|
||||
Text(post.localized(in: language).title).tag(post)
|
||||
}
|
||||
}.onAppear {
|
||||
if selectedPost == nil {
|
||||
selectedPost = content.posts.first
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -1,127 +0,0 @@
|
||||
import SwiftUI
|
||||
import SFSafeSymbols
|
||||
|
||||
struct PostView: View {
|
||||
|
||||
@ObservedObject
|
||||
var post: Post
|
||||
|
||||
@Environment(\.language)
|
||||
private var language
|
||||
|
||||
var body: some View {
|
||||
LocalizedPostView(post: post, localized: post.localized(in: language))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
struct LocalizedPostView: View {
|
||||
|
||||
@ObservedObject
|
||||
var post: Post
|
||||
|
||||
@ObservedObject
|
||||
var localized: LocalizedPost
|
||||
|
||||
@State
|
||||
private var showDatePicker = false
|
||||
|
||||
@State
|
||||
private var showImagePicker = false
|
||||
|
||||
@State
|
||||
private var showTagPicker = false
|
||||
|
||||
@Environment(\.language)
|
||||
private var language
|
||||
|
||||
@EnvironmentObject
|
||||
private var content: Content
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .center) {
|
||||
if localized.images.isEmpty {
|
||||
Button(action: { showImagePicker = true }) {
|
||||
Text("Add image")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(.blue)
|
||||
.padding(.top)
|
||||
} else {
|
||||
PostImageGalleryView(post: localized)
|
||||
.aspectRatio(1.33, contentMode: .fill)
|
||||
}
|
||||
VStack(alignment: .leading) {
|
||||
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)
|
||||
.textFieldStyle(.plain)
|
||||
.lineLimit(2)
|
||||
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.blue)
|
||||
.background(Circle()
|
||||
.fill(Color.white)
|
||||
.padding(1))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
TextEditor(text: $localized.content)
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
.textEditorStyle(.plain)
|
||||
.padding(.leading, -5)
|
||||
.scrollDisabled(true)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.background(Color(NSColor.windowBackgroundColor))
|
||||
.cornerRadius(8)
|
||||
.sheet(isPresented: $showDatePicker) {
|
||||
DatePickerView(
|
||||
post: post,
|
||||
showDatePicker: $showDatePicker)
|
||||
}
|
||||
.sheet(isPresented: $showImagePicker) {
|
||||
ImagePickerView(showImagePicker: $showImagePicker) { image in
|
||||
localized.images.append(image)
|
||||
}
|
||||
}
|
||||
.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 {
|
||||
PostView(post: .fullMock)
|
||||
.listRowSeparator(.hidden)
|
||||
.environment(\.language, ContentLanguage.german)
|
||||
PostView(post: .mock)
|
||||
.listRowSeparator(.hidden)
|
||||
}
|
||||
.environmentObject(Content.mock)
|
||||
//.listStyle(.plain)
|
||||
}
|
@ -4,22 +4,15 @@ import SFSafeSymbols
|
||||
|
||||
struct TagView: View {
|
||||
|
||||
@Environment(\.language)
|
||||
var language: ContentLanguage
|
||||
let text: String
|
||||
|
||||
let tag: LocalizedText
|
||||
|
||||
init(tag: LocalizedText) {
|
||||
self.tag = tag
|
||||
}
|
||||
|
||||
static var add: TagView {
|
||||
.init(tag: LocalizedText(en: "Add", de: "Mehr"))
|
||||
init(text: String) {
|
||||
self.text = text
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(tag.getText(for: language))
|
||||
Text(text)
|
||||
.font(.subheadline)
|
||||
.padding(.leading, 2)
|
||||
}
|
||||
@ -33,10 +26,7 @@ struct TagView: View {
|
||||
|
||||
#Preview {
|
||||
HStack {
|
||||
TagView(tag: LocalizedText(en: "Some", de: "Etwas"))
|
||||
.environment(\.language, ContentLanguage.german)
|
||||
TagView(tag: LocalizedText(en: "Some", de: "Etwas"))
|
||||
.environment(\.language, ContentLanguage.english)
|
||||
TagView.add
|
||||
TagView(text: "Some")
|
||||
TagView(text: "Etwas")
|
||||
}.background(Color.secondary)
|
||||
}
|
||||
|
Reference in New Issue
Block a user