Consolidate images and files

This commit is contained in:
Christoph Hagen
2024-12-09 12:18:55 +01:00
parent 394cf7a2e4
commit 4f08526978
77 changed files with 1970 additions and 1619 deletions

View 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)
}

View File

@ -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 {

View File

@ -35,7 +35,7 @@ struct LocalizedPostDetailView: View {
size: 22,
color: .red) {
item.linkPreviewImage = nil
}
}.disabled(item.linkPreviewImage == nil)
Spacer()
}

View File

@ -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 }) {

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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())
}

View 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
}
}
}
}

View File

@ -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)
}

View File

@ -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)
}