Generate tag overview, add file action

This commit is contained in:
Christoph Hagen
2025-01-05 09:21:21 +01:00
parent 0dca633805
commit 01baf560ee
27 changed files with 501 additions and 137 deletions

View File

@ -11,15 +11,11 @@ struct AddFileView: View {
@Binding
var selectedFile: FileResource?
@Binding
var selectedImage: ImageResource?
@State
private var filesToAdd: [FileToAdd] = []
init(selectedImage: Binding<ImageResource?>, selectedFile: Binding<FileResource?>) {
init(selectedFile: Binding<FileResource?>) {
_selectedFile = selectedFile
_selectedImage = selectedImage
}
var body: some View {
@ -111,6 +107,5 @@ struct AddFileView: View {
}
#Preview {
AddFileView(selectedImage: .constant(nil),
selectedFile: .constant(nil))
AddFileView(selectedFile: .constant(nil))
}

View File

@ -2,15 +2,36 @@ import SwiftUI
struct FileDetailView: View {
@EnvironmentObject
private var content: Content
@ObservedObject
var file: FileResource
@State
private var showFileSelection = false
@State
private var selectedFile: FileResource?
var body: some View {
VStack(alignment: .leading) {
DetailTitle(
title: "File",
text: "A file that can be used in a post or page")
VStack(alignment: .leading) {
Button("Show in Finder", action: showFileInFinder)
Button("Mark as changed", action: markFileAsChanged)
Button("Delete resource", action: deleteFile)
if file.isExternallyStored {
Button("Import file", action: replaceFile)
} else {
Button("Replace file", action: replaceFile)
Button("Make external", action: convertToExternal)
}
}
IdPropertyView(
id: $file.id,
title: "Name",
@ -28,15 +49,108 @@ struct FileDetailView: View {
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)
Text("\(Int(file.size.width)) x \(Int(file.size.height)) (\(file.aspectRatio))")
.foregroundStyle(.secondary)
if let imageDimensions = file.imageDimensions {
GenericPropertyView(title: "Image dimensions") {
Text("\(Int(imageDimensions.width)) x \(Int(imageDimensions.height)) (\(file.aspectRatio))")
}
#warning("Add button to show image versions")
}
if let fileSize = file.fileSize {
GenericPropertyView(title: "File size") {
Text(formatBytes(fileSize))
}
}
Spacer()
}.padding()
.onAppear {
if file.fileSize == nil {
file.determineFileSize()
}
}
}
private func formatBytes(_ bytes: Int) -> String {
let formatter = ByteCountFormatter()
formatter.allowedUnits = [.useMB, .useKB, .useBytes] // Customize units if needed
formatter.countStyle = .file
return formatter.string(fromByteCount: Int64(bytes))
}
private func showFileInFinder() {
content.storage.openFinderWindow(withSelectedFile: file.id)
}
private func markFileAsChanged() {
DispatchQueue.main.async {
file.determineImageDimensions()
file.determineFileSize()
// Force regeneration of images and/or file copying
file.removeFileFromOutputFolder()
// Trigger content view update to reload image
file.didChange()
}
}
private func replaceFile() {
guard let url = openFilePanel() else {
print("File '\(file.id)': No file selected as replacement")
return
}
guard content.storage.importExternalFile(at: url, fileId: file.id) else {
print("File '\(file.id)': Failed to replace file")
return
}
markFileAsChanged()
if file.isExternallyStored {
DispatchQueue.main.async {
file.isExternallyStored = false
}
}
}
private func openFilePanel() -> URL? {
let panel = NSOpenPanel()
panel.canChooseFiles = true
panel.canChooseDirectories = false
panel.allowsMultipleSelection = false
panel.showsHiddenFiles = false
panel.title = "Select file to replace"
panel.prompt = ""
let response = panel.runModal()
guard response == .OK else {
print("File '\(file.id)': Failed to select file to replace")
return nil
}
return panel.url
}
private func convertToExternal() {
guard !file.isExternallyStored else {
return
}
guard content.storage.delete(file: file.id) else {
print("File '\(file.id)': Failed to delete file to make it external")
return
}
DispatchQueue.main.async {
file.fileSize = nil
file.isExternallyStored = true
}
}
private func deleteFile() {
if !file.isExternallyStored {
guard content.storage.delete(file: file.id) else {
print("File '\(file.id)': Failed to delete file in content folder")
return
}
}
content.remove(file)
}
}

View File

@ -47,68 +47,71 @@ struct MultiFileSelectionView: View {
}
var body: some View {
HStack {
VStack {
Text("Selected files")
.font(.title)
List {
ForEach(newSelection) { file in
HStack {
Image(systemSymbol: .minusCircleFill)
.foregroundStyle(.red)
.contentShape(Rectangle())
.onTapGesture { deselect(file: file) }
Text(file.id)
Spacer()
GeometryReader { geo in
HStack {
VStack {
Text("Selected files")
.font(.title)
List {
ForEach(newSelection) { file in
HStack {
Image(systemSymbol: .minusCircleFill)
.foregroundStyle(.red)
.contentShape(Rectangle())
.onTapGesture { deselect(file: file) }
Text(file.id)
Spacer()
}
}
.onMove(perform: moveSelectedFile)
}
.onMove(perform: moveSelectedFile)
}
HStack {
Button("Cancel") {
DispatchQueue.main.async {
HStack {
Button("Cancel") {
DispatchQueue.main.async {
dismiss()
}
}
Button("Save") {
selectedFiles = newSelection
dismiss()
}
}
Button("Save") {
selectedFiles = newSelection
dismiss()
}
}
}
VStack {
Picker("", selection: $selectedFileType) {
let all: FileTypeCategory? = nil
Text("All").tag(all)
ForEach(FileTypeCategory.allCases) { type in
Text(type.text).tag(type)
}
}
.pickerStyle(.segmented)
.padding(.trailing, 7)
.disabled(allowedType != nil)
TextField("", text: $searchString, prompt: Text("Search"))
.textFieldStyle(.roundedBorder)
.padding(.horizontal, 8)
List(filteredFiles) { file in
HStack {
if newSelection.contains(file) {
Image(systemSymbol: .checkmarkCircleFill)
.foregroundStyle(.gray)
} else {
Image(systemSymbol: .plusCircleFill)
.foregroundStyle(.green)
}.frame(width: geo.size.width / 2)
VStack {
Picker("", selection: $selectedFileType) {
let all: FileTypeCategory? = nil
Text("All").tag(all)
ForEach(FileTypeCategory.allCases) { type in
Text(type.text).tag(type)
}
Text(file.id)
Spacer()
}
.contentShape(Rectangle())
.onTapGesture { select(file: file) }
}
.padding(.trailing, 7)
.disabled(allowedType != nil)
TextField("", text: $searchString, prompt: Text("Search"))
.textFieldStyle(.roundedBorder)
.padding(.horizontal, 8)
List(filteredFiles) { file in
HStack {
if newSelection.contains(file) {
Image(systemSymbol: .checkmarkCircleFill)
.foregroundStyle(.gray)
} else {
Image(systemSymbol: .plusCircleFill)
.foregroundStyle(.green)
}
Text(file.id)
Spacer()
}
.contentShape(Rectangle())
.onTapGesture { select(file: file) }
}
}.frame(width: geo.size.width / 2)
}
.frame(minHeight: 500, idealHeight: 600)
}
.frame(minHeight: 500, idealHeight: 600)
.padding()
}
private func deselect(file: FileResource) {

View File

@ -4,11 +4,11 @@ struct GenericPropertyView<Content>: View where Content: View {
let title: LocalizedStringKey
let footer: LocalizedStringKey
let footer: LocalizedStringKey?
let content: Content
public init(title: LocalizedStringKey, footer: LocalizedStringKey, @ViewBuilder content: () -> Content) {
public init(title: LocalizedStringKey, footer: LocalizedStringKey? = nil, @ViewBuilder content: () -> Content) {
self.title = title
self.footer = footer
self.content = content()
@ -18,10 +18,16 @@ struct GenericPropertyView<Content>: View where Content: View {
VStack(alignment: .leading) {
Text(title)
.font(.headline)
content
Text(footer)
.foregroundStyle(.secondary)
.padding(.bottom)
if let footer {
content
Text(footer)
.foregroundStyle(.secondary)
.padding(.bottom)
} else {
content
.padding(.bottom)
}
}
}
}

View File

@ -24,6 +24,12 @@ struct LocalizedPageDetailView: View {
footer: "Prevent the date and title from being printed on the page")
.disabled(isExternalPage)
if let url = page.originalUrl {
GenericPropertyView(title: "Original URL") {
Text(url)
}
}
OptionalStringPropertyView(
title: "Preview Title",
text: $page.linkPreviewTitle,

View File

@ -28,8 +28,8 @@ struct AddTagView: View {
content: content,
id: "tag",
isVisible: true,
german: .init(content: .mock, urlComponent: "tag", name: "Neuer Tag"),
english: .init(content: .mock, urlComponent: "tag-en", name: "New Tag"))
german: .init(content: content, urlComponent: "tag", name: "Neuer Tag"),
english: .init(content: content, 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

@ -24,19 +24,24 @@ struct LocalizedTagDetailView: View {
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)
if let url = tag.originalUrl {
GenericPropertyView(title: "Original URL") {
Text(url)
}
}
OptionalStringPropertyView(
title: "Subtitle",
text: $tag.subtitle,
footer: "The subtitle/tagline to use")
OptionalStringPropertyView(
title: "Preview Title",
text: $tag.linkPreviewTitle,
prompt: tag.name,
footer: "The title to use for the tag in previews and on tag pages")
OptionalImagePropertyView(
title: "Preview Image",
selectedImage: $tag.linkPreviewImage,

View File

@ -21,8 +21,17 @@ struct TagDetailView: View {
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.")
IdPropertyView(
id: $tag.id,
title: "Tag id",
footer: "The unique id of the tag for references",
validation: tag.isValid) {
tag.update(id: $0)
}
LocalizedTagDetailView(
tag: tag.localized(in: language))
.id(tag.id + language.rawValue)
}
.padding()
}