Generate tag overview, add file action
This commit is contained in:
parent
0dca633805
commit
01baf560ee
@ -48,9 +48,8 @@ final class GenerationResults: ObservableObject {
|
|||||||
|
|
||||||
private(set) var general: PageGenerationResults!
|
private(set) var general: PageGenerationResults!
|
||||||
|
|
||||||
var resultCount: Int {
|
@Published
|
||||||
cache.count
|
var resultCount: Int = 0
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: Life cycle
|
// MARK: Life cycle
|
||||||
|
|
||||||
@ -59,12 +58,14 @@ final class GenerationResults: ObservableObject {
|
|||||||
let general = PageGenerationResults(itemId: id, delegate: self)
|
let general = PageGenerationResults(itemId: id, delegate: self)
|
||||||
self.general = general
|
self.general = general
|
||||||
cache[id] = general
|
cache[id] = general
|
||||||
|
self.resultCount = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeResults(_ itemId: ItemId) -> PageGenerationResults {
|
func makeResults(_ itemId: ItemId) -> PageGenerationResults {
|
||||||
guard let result = cache[itemId] else {
|
guard let result = cache[itemId] else {
|
||||||
let result = PageGenerationResults(itemId: itemId, delegate: self)
|
let result = PageGenerationResults(itemId: itemId, delegate: self)
|
||||||
cache[itemId] = result
|
cache[itemId] = result
|
||||||
|
update { self.resultCount += 1 }
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
@ -116,7 +117,6 @@ final class GenerationResults: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// MARK: Adding entries
|
// MARK: Adding entries
|
||||||
|
|
||||||
func inaccessibleContent(file: FileResource) {
|
func inaccessibleContent(file: FileResource) {
|
||||||
|
@ -49,6 +49,7 @@ final class ImageGenerator {
|
|||||||
*/
|
*/
|
||||||
func removeVersions(of image: String) {
|
func removeVersions(of image: String) {
|
||||||
generatedImages[image] = nil
|
generatedImages[image] = nil
|
||||||
|
save()
|
||||||
}
|
}
|
||||||
|
|
||||||
func recalculateGeneratedImages(by images: Set<String>) {
|
func recalculateGeneratedImages(by images: Set<String>) {
|
||||||
@ -119,6 +120,10 @@ final class ImageGenerator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if version.type == .avif {
|
if version.type == .avif {
|
||||||
|
if version.image.type == .gif {
|
||||||
|
// Skip GIFs, since they can't be converted by avifenc
|
||||||
|
return true
|
||||||
|
}
|
||||||
// AVIF conversion is very slow, so we save bash commands
|
// AVIF conversion is very slow, so we save bash commands
|
||||||
// for the conversion instead
|
// for the conversion instead
|
||||||
let baseVersion = ImageVersion(
|
let baseVersion = ImageVersion(
|
||||||
|
@ -44,7 +44,7 @@ struct ImageSet {
|
|||||||
|
|
||||||
var result = "<picture>"
|
var result = "<picture>"
|
||||||
result += "<source type='image/avif' srcset='\(prefix1x).avif 1x, \(prefix2x).avif 2x'/>"
|
result += "<source type='image/avif' srcset='\(prefix1x).avif 1x, \(prefix2x).avif 2x'/>"
|
||||||
result += "<source type='image/webp' srcset='\(prefix1x).webm 1x, \(prefix1x).webm 2x'/>"
|
result += "<source type='image/webp' srcset='\(prefix1x).webp 1x, \(prefix1x).webp 2x'/>"
|
||||||
result += "<img srcset='\(prefix2x)\(fileExtension) 2x' src='\(prefix1x)\(fileExtension)' loading='lazy' alt='\(description.htmlEscaped())'/>"
|
result += "<img srcset='\(prefix2x)\(fileExtension) 2x' src='\(prefix1x)\(fileExtension)' loading='lazy' alt='\(description.htmlEscaped())'/>"
|
||||||
result += "</picture>"
|
result += "</picture>"
|
||||||
return result
|
return result
|
||||||
|
@ -1,4 +1,72 @@
|
|||||||
|
|
||||||
|
private struct TagData {
|
||||||
|
|
||||||
|
let url: String
|
||||||
|
|
||||||
|
let title: String
|
||||||
|
|
||||||
|
let localized: LocalizedTag
|
||||||
|
|
||||||
|
init(tag: Tag, language: ContentLanguage) {
|
||||||
|
let localized = tag.localized(in: language)
|
||||||
|
self.url = tag.absoluteUrl(in: language)
|
||||||
|
self.title = localized.linkPreviewTitle ?? localized.name
|
||||||
|
self.localized = localized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension TagData: Comparable {
|
||||||
|
|
||||||
|
static func < (lhs: TagData, rhs: TagData) -> Bool {
|
||||||
|
lhs.title < rhs.title
|
||||||
|
}
|
||||||
|
|
||||||
|
static func == (lhs: TagData, rhs: TagData) -> Bool {
|
||||||
|
lhs.title == rhs.title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct TagHeaderContent {
|
||||||
|
|
||||||
|
let language: ContentLanguage
|
||||||
|
|
||||||
|
let title: String
|
||||||
|
|
||||||
|
let description: String?
|
||||||
|
|
||||||
|
let iconUrl: String
|
||||||
|
|
||||||
|
let links: [NavigationBar.Link]
|
||||||
|
|
||||||
|
let headers: Set<HeaderElement>
|
||||||
|
|
||||||
|
let baseUrl: String
|
||||||
|
|
||||||
|
let localizedBaseUrl: String
|
||||||
|
|
||||||
|
private func url(pageNumber: Int) -> String {
|
||||||
|
baseUrl + "/\(pageNumber)"
|
||||||
|
}
|
||||||
|
|
||||||
|
func fileUrl(pageNumber: Int) -> String {
|
||||||
|
url(pageNumber: pageNumber) + ".html"
|
||||||
|
}
|
||||||
|
|
||||||
|
func pageHeader(pageNumber: Int) -> PageHeader {
|
||||||
|
.init(
|
||||||
|
language: language,
|
||||||
|
title: title,
|
||||||
|
description: description,
|
||||||
|
iconUrl: iconUrl,
|
||||||
|
languageButton: .init(
|
||||||
|
text: language.next.rawValue,
|
||||||
|
url: localizedBaseUrl + "/\(pageNumber)"),
|
||||||
|
links: links,
|
||||||
|
headers: headers,
|
||||||
|
icons: [])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
final class TagOverviewGenerator {
|
final class TagOverviewGenerator {
|
||||||
|
|
||||||
let content: Content
|
let content: Content
|
||||||
@ -13,49 +81,67 @@ final class TagOverviewGenerator {
|
|||||||
self.results = results
|
self.results = results
|
||||||
}
|
}
|
||||||
|
|
||||||
func generatePage(tags: [Tag], overview: TagOverviewPage) {
|
func generatePages(tags: [Tag], overview: TagOverviewPage) {
|
||||||
let iconUrl = content.settings.navigation.localized(in: language).rootUrl
|
|
||||||
let languageUrl = overview.absoluteUrl(in: language.next)
|
|
||||||
let languageButton = NavigationBar.Link(
|
|
||||||
text: language.next.rawValue,
|
|
||||||
url: languageUrl)
|
|
||||||
|
|
||||||
let localized = overview.localized(in: language)
|
let localized = overview.localized(in: language)
|
||||||
|
let header = TagHeaderContent(
|
||||||
let pageHeader = PageHeader(
|
|
||||||
language: language,
|
language: language,
|
||||||
title: localized.linkPreviewTitle ?? localized.title,
|
title: localized.linkPreviewTitle ?? localized.title,
|
||||||
description: localized.linkPreviewDescription,
|
description: localized.linkPreviewDescription,
|
||||||
iconUrl: iconUrl,
|
iconUrl: content.settings.navigation.localized(in: language).rootUrl,
|
||||||
languageButton: languageButton,
|
|
||||||
links: content.navigationBar(in: language),
|
links: content.navigationBar(in: language),
|
||||||
headers: content.defaultPageHeaders,
|
headers: content.defaultPageHeaders,
|
||||||
icons: [])
|
baseUrl: overview.absoluteUrl(in: language),
|
||||||
|
localizedBaseUrl: overview.absoluteUrl(in: language.next))
|
||||||
|
|
||||||
|
// Sort tags by title
|
||||||
|
let tagData = tags.map {
|
||||||
|
TagData(tag: $0, language: language)
|
||||||
|
}.sorted { $0.title }
|
||||||
|
|
||||||
|
let totalCount = tagData.count
|
||||||
|
guard totalCount > 0 else {
|
||||||
|
// Create one empty page
|
||||||
|
generatePage(tags: [], header: header, page: 1, totalPages: 1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let tagsPerPage = content.settings.posts.postsPerPage
|
||||||
|
|
||||||
|
let numberOfPages = (totalCount + tagsPerPage - 1) / tagsPerPage // Round up
|
||||||
|
for pageIndex in 1...numberOfPages {
|
||||||
|
let startIndex = (pageIndex - 1) * tagsPerPage
|
||||||
|
let endIndex = min(pageIndex * tagsPerPage, totalCount)
|
||||||
|
let tagsOnPage = tagData[startIndex..<endIndex]
|
||||||
|
generatePage(tags: tagsOnPage, header: header, page: pageIndex, totalPages: numberOfPages)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func generatePage(tags: ArraySlice<TagData>, header: TagHeaderContent, page pageNumber: Int, totalPages: Int) {
|
||||||
|
let pageHeader = header.pageHeader(pageNumber: pageNumber)
|
||||||
|
|
||||||
let page = GenericPage(
|
let page = GenericPage(
|
||||||
header: pageHeader,
|
header: pageHeader,
|
||||||
additionalFooter: "") { content in
|
additionalFooter: "") { content in
|
||||||
content += "<h1>\(localized.title)</h1>"
|
content += "<h1>\(header.title)</h1>"
|
||||||
for tag in tags {
|
for tag in tags {
|
||||||
let localized = tag.localized(in: self.language)
|
let description = tag.localized.description ?? ""
|
||||||
let url = tag.absoluteUrl(in: self.language)
|
let image = self.makePageImage(item: tag.localized)
|
||||||
let title = localized.name
|
|
||||||
let description = localized.description ?? ""
|
|
||||||
let image = self.makePageImage(item: localized)
|
|
||||||
|
|
||||||
content += RelatedPageLink(
|
content += RelatedPageLink(
|
||||||
title: title,
|
title: tag.title,
|
||||||
description: description,
|
description: description,
|
||||||
url: url,
|
url: tag.url,
|
||||||
image: image)
|
image: image)
|
||||||
.content
|
.content
|
||||||
}
|
}
|
||||||
// if totalPages > 1 {
|
if totalPages > 1 {
|
||||||
// content += PostFeedPageNavigation(currentPage: pageNumber, numberOfPages: totalPages, language: language).content
|
content += PostFeedPageNavigation(
|
||||||
// }
|
linkPrefix: header.baseUrl,
|
||||||
|
currentPage: pageNumber,
|
||||||
|
numberOfPages: totalPages).content
|
||||||
|
}
|
||||||
}
|
}
|
||||||
let fileContent = page.content
|
let fileContent = page.content
|
||||||
let url = overview.absoluteUrl(in: language) + ".html"
|
let url = header.fileUrl(pageNumber: pageNumber)
|
||||||
|
|
||||||
guard content.storage.write(fileContent, to: url) else {
|
guard content.storage.write(fileContent, to: url) else {
|
||||||
results.unsavedOutput(url, source: .tagOverview)
|
results.unsavedOutput(url, source: .tagOverview)
|
||||||
|
@ -23,6 +23,8 @@ final class PostListPageGenerator {
|
|||||||
func createPages(for posts: [Post]) {
|
func createPages(for posts: [Post]) {
|
||||||
let totalCount = posts.count
|
let totalCount = posts.count
|
||||||
guard totalCount > 0 else {
|
guard totalCount > 0 else {
|
||||||
|
// Create one empty page
|
||||||
|
createPostFeedPage(1, pageCount: 1, posts: [])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let postsPerPage = source.postsPerPage
|
let postsPerPage = source.postsPerPage
|
||||||
@ -80,7 +82,7 @@ final class PostListPageGenerator {
|
|||||||
pageNumber: pageIndex,
|
pageNumber: pageIndex,
|
||||||
totalPages: pageCount,
|
totalPages: pageCount,
|
||||||
languageButtonUrl: languageButtonUrl,
|
languageButtonUrl: languageButtonUrl,
|
||||||
linkPrefix: "/" + source.pageUrlPrefix(for: language) + "/")
|
linkPrefix: source.pageUrlPrefix(for: language))
|
||||||
let filePath = pageUrl(in: language, pageNumber: pageIndex) + ".html"
|
let filePath = pageUrl(in: language, pageNumber: pageIndex) + ".html"
|
||||||
guard save(fileContent, to: filePath) else {
|
guard save(fileContent, to: filePath) else {
|
||||||
source.results.unsavedOutput(filePath, source: .feed)
|
source.results.unsavedOutput(filePath, source: .feed)
|
||||||
|
@ -2,7 +2,6 @@ import SwiftUI
|
|||||||
import SFSafeSymbols
|
import SFSafeSymbols
|
||||||
|
|
||||||
#warning("Fix podcast")
|
#warning("Fix podcast")
|
||||||
#warning("Fix CV")
|
|
||||||
#warning("Fix endeavor basics (image compare)")
|
#warning("Fix endeavor basics (image compare)")
|
||||||
#warning("Fix cap mosaic GIF")
|
#warning("Fix cap mosaic GIF")
|
||||||
|
|
||||||
@ -23,6 +22,8 @@ import SFSafeSymbols
|
|||||||
#warning("Add author to settings and page headers")
|
#warning("Add author to settings and page headers")
|
||||||
#warning("Check for files in output folder not generated by app")
|
#warning("Check for files in output folder not generated by app")
|
||||||
#warning("Fix GIFs: Don't rescale, don't use image set")
|
#warning("Fix GIFs: Don't rescale, don't use image set")
|
||||||
|
#warning("Add view to browse images")
|
||||||
|
#warning("Show warnings for empty item properties")
|
||||||
|
|
||||||
@main
|
@main
|
||||||
struct MainView: App {
|
struct MainView: App {
|
||||||
@ -49,9 +50,6 @@ struct MainView: App {
|
|||||||
@State
|
@State
|
||||||
private var selectedTag: Tag?
|
private var selectedTag: Tag?
|
||||||
|
|
||||||
@State
|
|
||||||
private var selectedImage: ImageResource?
|
|
||||||
|
|
||||||
@State
|
@State
|
||||||
private var selectedFile: FileResource?
|
private var selectedFile: FileResource?
|
||||||
|
|
||||||
@ -122,7 +120,7 @@ struct MainView: App {
|
|||||||
case .tags:
|
case .tags:
|
||||||
AddTagView(selected: $selectedTag)
|
AddTagView(selected: $selectedTag)
|
||||||
case .files:
|
case .files:
|
||||||
AddFileView(selectedImage: $selectedImage, selectedFile: $selectedFile)
|
AddFileView(selectedFile: $selectedFile)
|
||||||
case .generation:
|
case .generation:
|
||||||
Text("Not implemented")
|
Text("Not implemented")
|
||||||
}
|
}
|
||||||
|
@ -11,6 +11,7 @@ extension Content {
|
|||||||
|
|
||||||
self.copyRequiredFiles()
|
self.copyRequiredFiles()
|
||||||
self.generateRequiredImages()
|
self.generateRequiredImages()
|
||||||
|
self.results.recalculate()
|
||||||
self.status("Generation completed")
|
self.status("Generation completed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -61,9 +62,6 @@ extension Content {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func generateRequiredImages() {
|
private func generateRequiredImages() {
|
||||||
let imageGenerator = ImageGenerator(
|
|
||||||
storage: storage,
|
|
||||||
settings: settings)
|
|
||||||
|
|
||||||
let images = results.imagesToGenerate.sorted()
|
let images = results.imagesToGenerate.sorted()
|
||||||
let count = images.count
|
let count = images.count
|
||||||
@ -255,7 +253,7 @@ extension Content {
|
|||||||
guard shouldGenerateWebsite else { return }
|
guard shouldGenerateWebsite else { return }
|
||||||
let results = results.makeResults(for: .tagOverview, in: language)
|
let results = results.makeResults(for: .tagOverview, in: language)
|
||||||
let generator = TagOverviewGenerator(content: self, language: language, results: results)
|
let generator = TagOverviewGenerator(content: self, language: language, results: results)
|
||||||
generator.generatePage(tags: tags, overview: tagOverview)
|
generator.generatePages(tags: tags, overview: tagOverview)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -10,6 +10,7 @@ extension Content {
|
|||||||
subtitle: tag.subtitle,
|
subtitle: tag.subtitle,
|
||||||
description: tag.description,
|
description: tag.description,
|
||||||
thumbnail: tag.thumbnail.map { images[$0] },
|
thumbnail: tag.thumbnail.map { images[$0] },
|
||||||
|
linkPreviewTitle: tag.linkPreviewTitle,
|
||||||
originalUrl: tag.originalURL)
|
originalUrl: tag.originalURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -129,6 +129,7 @@ private extension LocalizedTag {
|
|||||||
name: name,
|
name: name,
|
||||||
subtitle: subtitle,
|
subtitle: subtitle,
|
||||||
description: description,
|
description: description,
|
||||||
|
linkPreviewTitle: linkPreviewTitle,
|
||||||
thumbnail: linkPreviewImage?.id,
|
thumbnail: linkPreviewImage?.id,
|
||||||
originalURL: originalUrl)
|
originalURL: originalUrl)
|
||||||
}
|
}
|
||||||
|
@ -7,7 +7,7 @@ extension Content {
|
|||||||
private static let disallowedCharactersInFileIds = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-.")).inverted
|
private static let disallowedCharactersInFileIds = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-.")).inverted
|
||||||
|
|
||||||
func isNewIdForTag(_ id: String) -> Bool {
|
func isNewIdForTag(_ id: String) -> Bool {
|
||||||
!tags.contains { $0.id == id }
|
tagOverview?.id != id && !tags.contains { $0.id == id }
|
||||||
}
|
}
|
||||||
|
|
||||||
func isNewIdForPage(_ id: String) -> Bool {
|
func isNewIdForPage(_ id: String) -> Bool {
|
||||||
|
@ -37,6 +37,8 @@ final class Content: ObservableObject {
|
|||||||
@Published
|
@Published
|
||||||
private(set) var shouldGenerateWebsite = false
|
private(set) var shouldGenerateWebsite = false
|
||||||
|
|
||||||
|
let imageGenerator: ImageGenerator
|
||||||
|
|
||||||
init(settings: Settings,
|
init(settings: Settings,
|
||||||
posts: [Post],
|
posts: [Post],
|
||||||
pages: [Page],
|
pages: [Page],
|
||||||
@ -53,6 +55,9 @@ final class Content: ObservableObject {
|
|||||||
|
|
||||||
let storage = Storage()
|
let storage = Storage()
|
||||||
self.storage = storage
|
self.storage = storage
|
||||||
|
self.imageGenerator = ImageGenerator(
|
||||||
|
storage: storage,
|
||||||
|
settings: settings)
|
||||||
}
|
}
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
@ -67,6 +72,9 @@ final class Content: ObservableObject {
|
|||||||
|
|
||||||
let storage = Storage()
|
let storage = Storage()
|
||||||
self.storage = storage
|
self.storage = storage
|
||||||
|
self.imageGenerator = ImageGenerator(
|
||||||
|
storage: storage,
|
||||||
|
settings: settings)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func clear() {
|
private func clear() {
|
||||||
@ -112,4 +120,9 @@ final class Content: ObservableObject {
|
|||||||
print("Failed to reload content: \(error)")
|
print("Failed to reload content: \(error)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func remove(_ file: FileResource) {
|
||||||
|
files.remove(file)
|
||||||
|
#warning("Remove file from required files, thumbnails, post images, etc.")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -14,8 +14,13 @@ final class FileResource: Item {
|
|||||||
@Published
|
@Published
|
||||||
var english: String
|
var english: String
|
||||||
|
|
||||||
|
/// The dimensions of the image
|
||||||
@Published
|
@Published
|
||||||
var size: CGSize = .zero
|
var imageDimensions: CGSize? = nil
|
||||||
|
|
||||||
|
/// The size of the file in bytes
|
||||||
|
@Published
|
||||||
|
var fileSize: Int? = nil
|
||||||
|
|
||||||
init(content: Content, id: String, isExternallyStored: Bool, en: String, de: String) {
|
init(content: Content, id: String, isExternallyStored: Bool, en: String, de: String) {
|
||||||
self.type = FileType(fileExtension: id.fileExtension)
|
self.type = FileType(fileExtension: id.fileExtension)
|
||||||
@ -49,10 +54,13 @@ final class FileResource: Item {
|
|||||||
// MARK: Images
|
// MARK: Images
|
||||||
|
|
||||||
var aspectRatio: CGFloat {
|
var aspectRatio: CGFloat {
|
||||||
guard size.height > 0 else {
|
guard let imageDimensions else {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
return size.width / size.height
|
guard imageDimensions.height > 0 else {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return imageDimensions.width / imageDimensions.height
|
||||||
}
|
}
|
||||||
|
|
||||||
var imageToDisplay: Image {
|
var imageToDisplay: Image {
|
||||||
@ -60,18 +68,58 @@ final class FileResource: Item {
|
|||||||
print("Failed to load data for image \(id)")
|
print("Failed to load data for image \(id)")
|
||||||
return failureImage
|
return failureImage
|
||||||
}
|
}
|
||||||
|
if fileSize == nil {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.fileSize = imageData.count
|
||||||
|
}
|
||||||
|
}
|
||||||
guard let loadedImage = NSImage(data: imageData) else {
|
guard let loadedImage = NSImage(data: imageData) else {
|
||||||
print("Failed to create image \(id)")
|
print("Failed to create image \(id)")
|
||||||
return failureImage
|
return failureImage
|
||||||
}
|
}
|
||||||
if self.size == .zero && loadedImage.size != .zero {
|
if loadedImage.size != imageDimensions {
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
self.size = loadedImage.size
|
self.imageDimensions = loadedImage.size
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return .init(nsImage: loadedImage)
|
return .init(nsImage: loadedImage)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func determineImageDimensions() {
|
||||||
|
let size = getImageDimensions()
|
||||||
|
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.imageDimensions = size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func getImageDimensions() -> CGSize? {
|
||||||
|
guard type.isImage else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
guard let imageData = content.storage.fileData(for: id) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
guard let loadedImage = NSImage(data: imageData) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return loadedImage.size
|
||||||
|
}
|
||||||
|
|
||||||
|
func determineFileSize() {
|
||||||
|
DispatchQueue.global(qos: .userInitiated).async {
|
||||||
|
let size = self.content.storage.size(of: self.id)
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.fileSize = size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeGeneratedImages() {
|
||||||
|
content.imageGenerator.removeVersions(of: id)
|
||||||
|
content.storage.deleteInOutputFolder(path: outputImageFolder)
|
||||||
|
}
|
||||||
|
|
||||||
private var failureImage: Image {
|
private var failureImage: Image {
|
||||||
Image(systemSymbol: .exclamationmarkTriangle)
|
Image(systemSymbol: .exclamationmarkTriangle)
|
||||||
}
|
}
|
||||||
@ -105,6 +153,13 @@ final class FileResource: Item {
|
|||||||
|
|
||||||
// MARK: Paths
|
// MARK: Paths
|
||||||
|
|
||||||
|
func removeFileFromOutputFolder() {
|
||||||
|
content.storage.deleteInOutputFolder(path: absoluteUrl)
|
||||||
|
if type.isImage {
|
||||||
|
removeGeneratedImages()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
Get the url path to a file in the output folder.
|
Get the url path to a file in the output folder.
|
||||||
The result is an absolute path from the output folder for use in HTML.
|
The result is an absolute path from the output folder for use in HTML.
|
||||||
|
@ -4,6 +4,10 @@ class Item: ObservableObject, Identifiable {
|
|||||||
|
|
||||||
unowned let content: Content
|
unowned let content: Content
|
||||||
|
|
||||||
|
/// A dummy property to force views to update when properties change
|
||||||
|
@Published
|
||||||
|
private var changeToggle = false
|
||||||
|
|
||||||
@Published
|
@Published
|
||||||
var id: String
|
var id: String
|
||||||
|
|
||||||
@ -12,6 +16,10 @@ class Item: ObservableObject, Identifiable {
|
|||||||
self.id = id
|
self.id = id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func didChange() {
|
||||||
|
changeToggle.toggle()
|
||||||
|
}
|
||||||
|
|
||||||
func makeCleanAbsolutePath(_ path: String) -> String {
|
func makeCleanAbsolutePath(_ path: String) -> String {
|
||||||
"/" + makeCleanRelativePath(path)
|
"/" + makeCleanRelativePath(path)
|
||||||
}
|
}
|
||||||
|
@ -21,6 +21,9 @@ final class LocalizedTag: ObservableObject {
|
|||||||
@Published
|
@Published
|
||||||
var linkPreviewImage: FileResource?
|
var linkPreviewImage: FileResource?
|
||||||
|
|
||||||
|
@Published
|
||||||
|
var linkPreviewTitle: String?
|
||||||
|
|
||||||
/// The original url in the previous site layout
|
/// The original url in the previous site layout
|
||||||
let originalUrl: String?
|
let originalUrl: String?
|
||||||
|
|
||||||
@ -30,6 +33,7 @@ final class LocalizedTag: ObservableObject {
|
|||||||
subtitle: String? = nil,
|
subtitle: String? = nil,
|
||||||
description: String? = nil,
|
description: String? = nil,
|
||||||
thumbnail: FileResource? = nil,
|
thumbnail: FileResource? = nil,
|
||||||
|
linkPreviewTitle: String? = nil,
|
||||||
originalUrl: String? = nil) {
|
originalUrl: String? = nil) {
|
||||||
self.content = content
|
self.content = content
|
||||||
self.urlComponent = urlComponent
|
self.urlComponent = urlComponent
|
||||||
@ -37,6 +41,7 @@ final class LocalizedTag: ObservableObject {
|
|||||||
self.subtitle = subtitle
|
self.subtitle = subtitle
|
||||||
self.description = description
|
self.description = description
|
||||||
self.linkPreviewImage = thumbnail
|
self.linkPreviewImage = thumbnail
|
||||||
|
self.linkPreviewTitle = linkPreviewTitle
|
||||||
self.originalUrl = originalUrl
|
self.originalUrl = originalUrl
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -48,10 +53,6 @@ final class LocalizedTag: ObservableObject {
|
|||||||
|
|
||||||
extension LocalizedTag: LinkPreviewItem {
|
extension LocalizedTag: LinkPreviewItem {
|
||||||
|
|
||||||
var linkPreviewTitle: String? {
|
|
||||||
self.name
|
|
||||||
}
|
|
||||||
|
|
||||||
var linkPreviewDescription: String? {
|
var linkPreviewDescription: String? {
|
||||||
description
|
description
|
||||||
}
|
}
|
||||||
|
@ -26,12 +26,19 @@ final class Tag: Item {
|
|||||||
super.init(content: content, id: id)
|
super.init(content: content, id: id)
|
||||||
}
|
}
|
||||||
|
|
||||||
var linkName: String {
|
func isValid(id: String) -> Bool {
|
||||||
id.lowercased().replacingOccurrences(of: " ", with: "-")
|
content.isValidIdForTagOrPageOrPost(id) &&
|
||||||
|
content.isNewIdForTag(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
var url: String {
|
@discardableResult
|
||||||
"/tags/\(linkName).html"
|
func update(id newId: String) -> Bool {
|
||||||
|
guard content.storage.move(tag: id, to: newId) else {
|
||||||
|
print("Failed to move files of tag \(id)")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
id = newId
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Paths
|
// MARK: Paths
|
||||||
|
@ -15,7 +15,7 @@ struct PostFeedPageNavigation {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func pageLink(_ page: Int) -> String {
|
private func pageLink(_ page: Int) -> String {
|
||||||
"href='\(linkPrefix)\(page)'"
|
"href='/\(linkPrefix)/\(page)'"
|
||||||
}
|
}
|
||||||
|
|
||||||
private func addPreviousButton(to result: inout String) {
|
private func addPreviousButton(to result: inout String) {
|
||||||
|
@ -28,6 +28,8 @@ struct LocalizedTagFile {
|
|||||||
|
|
||||||
let description: String?
|
let description: String?
|
||||||
|
|
||||||
|
let linkPreviewTitle: String?
|
||||||
|
|
||||||
/// The image id of the thumbnail
|
/// The image id of the thumbnail
|
||||||
let thumbnail: String?
|
let thumbnail: String?
|
||||||
|
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import AppKit
|
||||||
|
|
||||||
struct SecurityBookmark {
|
struct SecurityBookmark {
|
||||||
|
|
||||||
@ -28,6 +29,14 @@ struct SecurityBookmark {
|
|||||||
|
|
||||||
// MARK: Write
|
// MARK: Write
|
||||||
|
|
||||||
|
func openFinderWindow(relativePath: String) {
|
||||||
|
with(relativePath: relativePath) { path in
|
||||||
|
print("Opening file at \(path)")
|
||||||
|
NSWorkspace.shared.activateFileViewerSelecting([path])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func fullPath(to relativePath: String) -> URL {
|
func fullPath(to relativePath: String) -> URL {
|
||||||
url.appending(path: relativePath, directoryHint: .notDirectory)
|
url.appending(path: relativePath, directoryHint: .notDirectory)
|
||||||
}
|
}
|
||||||
@ -103,6 +112,10 @@ struct SecurityBookmark {
|
|||||||
|
|
||||||
// MARK: Read
|
// MARK: Read
|
||||||
|
|
||||||
|
func size(of relativePath: String) -> Int? {
|
||||||
|
with(relativePath: relativePath) { $0.size }
|
||||||
|
}
|
||||||
|
|
||||||
func hasFile(at relativePath: String) -> Bool {
|
func hasFile(at relativePath: String) -> Bool {
|
||||||
with(relativePath: relativePath, perform: exists)
|
with(relativePath: relativePath, perform: exists)
|
||||||
}
|
}
|
||||||
@ -212,6 +225,7 @@ struct SecurityBookmark {
|
|||||||
func deleteFile(at relativePath: String) -> Bool {
|
func deleteFile(at relativePath: String) -> Bool {
|
||||||
with(relativePath: relativePath) { file in
|
with(relativePath: relativePath) { file in
|
||||||
guard exists(file) else {
|
guard exists(file) else {
|
||||||
|
print("Scope: No file to delete at \(file.path())")
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
do {
|
do {
|
||||||
|
@ -183,13 +183,13 @@ final class Storage: ObservableObject {
|
|||||||
tagId + ".json"
|
tagId + ".json"
|
||||||
}
|
}
|
||||||
|
|
||||||
private func tagFilePath(tagId: String) -> String {
|
private func tagFilePath(tag tagId: String) -> String {
|
||||||
tagsFolderName + "/" + tagFileName(tagId: tagId)
|
tagsFolderName + "/" + tagFileName(tagId: tagId)
|
||||||
}
|
}
|
||||||
|
|
||||||
func save(tagMetadata: TagFile, for tagId: String) -> Bool {
|
func save(tagMetadata: TagFile, for tagId: String) -> Bool {
|
||||||
guard let contentScope else { return false }
|
guard let contentScope else { return false }
|
||||||
let path = tagFilePath(tagId: tagId)
|
let path = tagFilePath(tag: tagId)
|
||||||
return contentScope.encode(tagMetadata, to: path)
|
return contentScope.encode(tagMetadata, to: path)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -211,6 +211,11 @@ final class Storage: ObservableObject {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func move(tag tagId: String, to newId: String) -> Bool {
|
||||||
|
guard let contentScope else { return false }
|
||||||
|
return contentScope.move(tagFilePath(tag: tagId), to: tagFilePath(tag: newId))
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: File descriptions
|
// MARK: File descriptions
|
||||||
|
|
||||||
func loadFileDescriptions() -> [FileDescriptions]? {
|
func loadFileDescriptions() -> [FileDescriptions]? {
|
||||||
@ -235,6 +240,10 @@ final class Storage: ObservableObject {
|
|||||||
|
|
||||||
// MARK: Files
|
// MARK: Files
|
||||||
|
|
||||||
|
func size(of file: String) -> Int? {
|
||||||
|
contentScope?.size(of: filePath(file: file))
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
The full path to a resource file in the content folder
|
The full path to a resource file in the content folder
|
||||||
- Parameter file: The filename of the file
|
- Parameter file: The filename of the file
|
||||||
@ -244,6 +253,16 @@ final class Storage: ObservableObject {
|
|||||||
contentScope?.fullPath(to: filePath(file: file))
|
contentScope?.fullPath(to: filePath(file: file))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
Delete a file resource from the content folder
|
||||||
|
*/
|
||||||
|
func delete(file fileId: String) -> Bool {
|
||||||
|
guard let contentScope else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return contentScope.deleteFile(at: filePath(file: fileId))
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
The full file path to a file in the output folder
|
The full file path to a file in the output folder
|
||||||
- Parameter relativePath: The path of the file relative to the output folder
|
- Parameter relativePath: The path of the file relative to the output folder
|
||||||
@ -252,10 +271,20 @@ final class Storage: ObservableObject {
|
|||||||
outputScope?.fullPath(to: relativePath)
|
outputScope?.fullPath(to: relativePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func openFinderWindow(withSelectedFile file: String) {
|
||||||
|
contentScope?.openFinderWindow(relativePath: filePath(file: file))
|
||||||
|
}
|
||||||
|
|
||||||
private func filePath(file fileId: String) -> String {
|
private func filePath(file fileId: String) -> String {
|
||||||
filesFolderName + "/" + fileId
|
filesFolderName + "/" + fileId
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func deleteInOutputFolder(path: String) -> Bool {
|
||||||
|
guard let outputScope else { return false }
|
||||||
|
return outputScope.deleteFile(at: path)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
Copy an external file to the content folder
|
Copy an external file to the content folder
|
||||||
*/
|
*/
|
||||||
@ -264,11 +293,17 @@ final class Storage: ObservableObject {
|
|||||||
return contentScope.copy(externalFile: url, to: filePath(file: fileId))
|
return contentScope.copy(externalFile: url, to: filePath(file: fileId))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
Move (rename) a file resource.
|
||||||
|
*/
|
||||||
func move(file fileId: String, to newId: String) -> Bool {
|
func move(file fileId: String, to newId: String) -> Bool {
|
||||||
guard let contentScope else { return false }
|
guard let contentScope else { return false }
|
||||||
return contentScope.move(filePath(file: fileId), to: filePath(file: newId))
|
return contentScope.move(filePath(file: fileId), to: filePath(file: newId))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
Copy a file resource to a path relative to the output folder
|
||||||
|
*/
|
||||||
func copy(file fileId: String, to relativeOutputPath: String) -> Bool {
|
func copy(file fileId: String, to relativeOutputPath: String) -> Bool {
|
||||||
guard let contentScope, let outputScope else { return false }
|
guard let contentScope, let outputScope else { return false }
|
||||||
return contentScope.transfer(
|
return contentScope.transfer(
|
||||||
|
@ -11,15 +11,11 @@ struct AddFileView: View {
|
|||||||
@Binding
|
@Binding
|
||||||
var selectedFile: FileResource?
|
var selectedFile: FileResource?
|
||||||
|
|
||||||
@Binding
|
|
||||||
var selectedImage: ImageResource?
|
|
||||||
|
|
||||||
@State
|
@State
|
||||||
private var filesToAdd: [FileToAdd] = []
|
private var filesToAdd: [FileToAdd] = []
|
||||||
|
|
||||||
init(selectedImage: Binding<ImageResource?>, selectedFile: Binding<FileResource?>) {
|
init(selectedFile: Binding<FileResource?>) {
|
||||||
_selectedFile = selectedFile
|
_selectedFile = selectedFile
|
||||||
_selectedImage = selectedImage
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@ -111,6 +107,5 @@ struct AddFileView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
AddFileView(selectedImage: .constant(nil),
|
AddFileView(selectedFile: .constant(nil))
|
||||||
selectedFile: .constant(nil))
|
|
||||||
}
|
}
|
||||||
|
@ -2,15 +2,36 @@ import SwiftUI
|
|||||||
|
|
||||||
struct FileDetailView: View {
|
struct FileDetailView: View {
|
||||||
|
|
||||||
|
@EnvironmentObject
|
||||||
|
private var content: Content
|
||||||
|
|
||||||
@ObservedObject
|
@ObservedObject
|
||||||
var file: FileResource
|
var file: FileResource
|
||||||
|
|
||||||
|
@State
|
||||||
|
private var showFileSelection = false
|
||||||
|
|
||||||
|
@State
|
||||||
|
private var selectedFile: FileResource?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
DetailTitle(
|
DetailTitle(
|
||||||
title: "File",
|
title: "File",
|
||||||
text: "A file that can be used in a post or page")
|
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(
|
IdPropertyView(
|
||||||
id: $file.id,
|
id: $file.id,
|
||||||
title: "Name",
|
title: "Name",
|
||||||
@ -28,15 +49,108 @@ struct FileDetailView: View {
|
|||||||
text: $file.english,
|
text: $file.english,
|
||||||
footer: "The description for the file in English. Descriptions are used for images and to explain the content of a file.")
|
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 {
|
if let imageDimensions = file.imageDimensions {
|
||||||
Text("Image size")
|
GenericPropertyView(title: "Image dimensions") {
|
||||||
.font(.headline)
|
Text("\(Int(imageDimensions.width)) x \(Int(imageDimensions.height)) (\(file.aspectRatio))")
|
||||||
Text("\(Int(file.size.width)) x \(Int(file.size.height)) (\(file.aspectRatio))")
|
}
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
#warning("Add button to show image versions")
|
#warning("Add button to show image versions")
|
||||||
}
|
}
|
||||||
|
if let fileSize = file.fileSize {
|
||||||
|
GenericPropertyView(title: "File size") {
|
||||||
|
Text(formatBytes(fileSize))
|
||||||
|
}
|
||||||
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
}.padding()
|
}.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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -47,6 +47,7 @@ struct MultiFileSelectionView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
GeometryReader { geo in
|
||||||
HStack {
|
HStack {
|
||||||
VStack {
|
VStack {
|
||||||
Text("Selected files")
|
Text("Selected files")
|
||||||
@ -75,7 +76,7 @@ struct MultiFileSelectionView: View {
|
|||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}.frame(width: geo.size.width / 2)
|
||||||
VStack {
|
VStack {
|
||||||
Picker("", selection: $selectedFileType) {
|
Picker("", selection: $selectedFileType) {
|
||||||
let all: FileTypeCategory? = nil
|
let all: FileTypeCategory? = nil
|
||||||
@ -84,7 +85,6 @@ struct MultiFileSelectionView: View {
|
|||||||
Text(type.text).tag(type)
|
Text(type.text).tag(type)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.pickerStyle(.segmented)
|
|
||||||
.padding(.trailing, 7)
|
.padding(.trailing, 7)
|
||||||
.disabled(allowedType != nil)
|
.disabled(allowedType != nil)
|
||||||
TextField("", text: $searchString, prompt: Text("Search"))
|
TextField("", text: $searchString, prompt: Text("Search"))
|
||||||
@ -105,10 +105,13 @@ struct MultiFileSelectionView: View {
|
|||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
.onTapGesture { select(file: file) }
|
.onTapGesture { select(file: file) }
|
||||||
}
|
}
|
||||||
|
}.frame(width: geo.size.width / 2)
|
||||||
}
|
}
|
||||||
|
.frame(minHeight: 500, idealHeight: 600)
|
||||||
}
|
}
|
||||||
.frame(minHeight: 500, idealHeight: 600)
|
.frame(minHeight: 500, idealHeight: 600)
|
||||||
.padding()
|
.padding()
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func deselect(file: FileResource) {
|
private func deselect(file: FileResource) {
|
||||||
|
@ -4,11 +4,11 @@ struct GenericPropertyView<Content>: View where Content: View {
|
|||||||
|
|
||||||
let title: LocalizedStringKey
|
let title: LocalizedStringKey
|
||||||
|
|
||||||
let footer: LocalizedStringKey
|
let footer: LocalizedStringKey?
|
||||||
|
|
||||||
let content: Content
|
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.title = title
|
||||||
self.footer = footer
|
self.footer = footer
|
||||||
self.content = content()
|
self.content = content()
|
||||||
@ -18,10 +18,16 @@ struct GenericPropertyView<Content>: View where Content: View {
|
|||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
Text(title)
|
Text(title)
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
|
|
||||||
|
if let footer {
|
||||||
content
|
content
|
||||||
Text(footer)
|
Text(footer)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.padding(.bottom)
|
.padding(.bottom)
|
||||||
|
} else {
|
||||||
|
content
|
||||||
|
.padding(.bottom)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -24,6 +24,12 @@ struct LocalizedPageDetailView: View {
|
|||||||
footer: "Prevent the date and title from being printed on the page")
|
footer: "Prevent the date and title from being printed on the page")
|
||||||
.disabled(isExternalPage)
|
.disabled(isExternalPage)
|
||||||
|
|
||||||
|
if let url = page.originalUrl {
|
||||||
|
GenericPropertyView(title: "Original URL") {
|
||||||
|
Text(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
OptionalStringPropertyView(
|
OptionalStringPropertyView(
|
||||||
title: "Preview Title",
|
title: "Preview Title",
|
||||||
text: $page.linkPreviewTitle,
|
text: $page.linkPreviewTitle,
|
||||||
|
@ -28,8 +28,8 @@ struct AddTagView: View {
|
|||||||
content: content,
|
content: content,
|
||||||
id: "tag",
|
id: "tag",
|
||||||
isVisible: true,
|
isVisible: true,
|
||||||
german: .init(content: .mock, urlComponent: "tag", name: "Neuer Tag"),
|
german: .init(content: content, urlComponent: "tag", name: "Neuer Tag"),
|
||||||
english: .init(content: .mock, urlComponent: "tag-en", name: "New Tag"))
|
english: .init(content: content, urlComponent: "tag-en", name: "New Tag"))
|
||||||
// Add to top of the list, and resort when changing the name
|
// Add to top of the list, and resort when changing the name
|
||||||
content.tags.insert(newTag, at: 0)
|
content.tags.insert(newTag, at: 0)
|
||||||
dismiss()
|
dismiss()
|
||||||
|
@ -25,18 +25,23 @@ struct LocalizedTagDetailView: View {
|
|||||||
validation: tag.isValid,
|
validation: tag.isValid,
|
||||||
update: { tag.urlComponent = $0 })
|
update: { tag.urlComponent = $0 })
|
||||||
|
|
||||||
Text("Original url")
|
if let url = tag.originalUrl {
|
||||||
.font(.headline)
|
GenericPropertyView(title: "Original URL") {
|
||||||
Text(tag.originalUrl ?? "-")
|
Text(url)
|
||||||
.foregroundStyle(.secondary)
|
}
|
||||||
.padding(.top, 1)
|
}
|
||||||
.padding(.bottom)
|
|
||||||
|
|
||||||
OptionalStringPropertyView(
|
OptionalStringPropertyView(
|
||||||
title: "Subtitle",
|
title: "Subtitle",
|
||||||
text: $tag.subtitle,
|
text: $tag.subtitle,
|
||||||
footer: "The subtitle/tagline to use")
|
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(
|
OptionalImagePropertyView(
|
||||||
title: "Preview Image",
|
title: "Preview Image",
|
||||||
selectedImage: $tag.linkPreviewImage,
|
selectedImage: $tag.linkPreviewImage,
|
||||||
|
@ -21,8 +21,17 @@ struct TagDetailView: View {
|
|||||||
value: $tag.isVisible,
|
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.")
|
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(
|
LocalizedTagDetailView(
|
||||||
tag: tag.localized(in: language))
|
tag: tag.localized(in: language))
|
||||||
|
.id(tag.id + language.rawValue)
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user