Compare commits

...

3 Commits

Author SHA1 Message Date
Christoph Hagen
f3de1b72b6 Add gpx type, fix file replacement 2025-04-29 16:57:02 +02:00
Christoph Hagen
3c7681b769 Add route block 2025-04-29 16:56:46 +02:00
Christoph Hagen
bbb1143600 Show external files in generation 2025-04-29 10:40:15 +02:00
18 changed files with 486 additions and 14 deletions

View File

@ -197,6 +197,11 @@
E2DD04742C276F31003BFF1F /* MainView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2DD04732C276F31003BFF1F /* MainView.swift */; };
E2DD047A2C276F32003BFF1F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = E2DD04792C276F32003BFF1F /* Assets.xcassets */; };
E2E06DFB2CA4A65E0019C2AF /* Content.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2E06DFA2CA4A6570019C2AF /* Content.swift */; };
E2EC1FAB2DC0C99600C41784 /* RouteViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2EC1FAA2DC0C98C00C41784 /* RouteViews.swift */; };
E2EC1FAD2DC0D2FA00C41784 /* RouteLocalization.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2EC1FAC2DC0D2FA00C41784 /* RouteLocalization.swift */; };
E2EC1FB02DC0D7DA00C41784 /* RouteBlock.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2EC1FAF2DC0D7D600C41784 /* RouteBlock.swift */; };
E2EC1FB22DC0D8BD00C41784 /* RouteViewComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2EC1FB12DC0D8BD00C41784 /* RouteViewComponents.swift */; };
E2EC1FB42DC0FA8700C41784 /* Insert+Route.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2EC1FB32DC0FA6D00C41784 /* Insert+Route.swift */; };
E2FD1D0D2D2DBBA600B48627 /* LinkPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FD1D0C2D2DBBA100B48627 /* LinkPreview.swift */; };
E2FD1D192D2DC4F500B48627 /* LoadingContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FD1D182D2DC4F500B48627 /* LoadingContext.swift */; };
E2FD1D1B2D2DC63800B48627 /* LinkPreviewDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FD1D1A2D2DC62C00B48627 /* LinkPreviewDetailView.swift */; };
@ -470,6 +475,11 @@
E2DD047D2C276F32003BFF1F /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = "<group>"; };
E2E06DFA2CA4A6570019C2AF /* Content.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Content.swift; sourceTree = "<group>"; };
E2E06DFF2CA4A8EB0019C2AF /* Page+Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Page+Mock.swift"; sourceTree = "<group>"; };
E2EC1FAA2DC0C98C00C41784 /* RouteViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteViews.swift; sourceTree = "<group>"; };
E2EC1FAC2DC0D2FA00C41784 /* RouteLocalization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteLocalization.swift; sourceTree = "<group>"; };
E2EC1FAF2DC0D7D600C41784 /* RouteBlock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteBlock.swift; sourceTree = "<group>"; };
E2EC1FB12DC0D8BD00C41784 /* RouteViewComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteViewComponents.swift; sourceTree = "<group>"; };
E2EC1FB32DC0FA6D00C41784 /* Insert+Route.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Insert+Route.swift"; sourceTree = "<group>"; };
E2FD1D0C2D2DBBA100B48627 /* LinkPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkPreview.swift; sourceTree = "<group>"; };
E2FD1D182D2DC4F500B48627 /* LoadingContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingContext.swift; sourceTree = "<group>"; };
E2FD1D1A2D2DC62C00B48627 /* LinkPreviewDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkPreviewDetailView.swift; sourceTree = "<group>"; };
@ -712,6 +722,7 @@
E29D311E2D0320D90051B7F4 /* ContentElements */ = {
isa = PBXGroup;
children = (
E2EC1FAE2DC0D30100C41784 /* Routes */,
E2BF1BC52D6B16FA003089F1 /* HeadlineLink.swift */,
E2B4821F2D67074C005C309D /* WallpaperSlider.swift */,
E29D31C12D0DBED70051B7F4 /* AudioPlayer */,
@ -1063,6 +1074,16 @@
path = "Preview Content";
sourceTree = "<group>";
};
E2EC1FAE2DC0D30100C41784 /* Routes */ = {
isa = PBXGroup;
children = (
E2EC1FB12DC0D8BD00C41784 /* RouteViewComponents.swift */,
E2EC1FAC2DC0D2FA00C41784 /* RouteLocalization.swift */,
E2EC1FAA2DC0C98C00C41784 /* RouteViews.swift */,
);
path = Routes;
sourceTree = "<group>";
};
E2FD1D262D2EBBA300B48627 /* Loading */ = {
isa = PBXGroup;
children = (
@ -1083,6 +1104,7 @@
E2FD1D382D3BBECA00B48627 /* InsertableView.swift */,
E2FD1D672D483CCA00B48627 /* Insert+Buttons.swift */,
E2FD1D362D3BBCB500B48627 /* Insert+Image.swift */,
E2EC1FB32DC0FA6D00C41784 /* Insert+Route.swift */,
E2FD1D552D46CED500B48627 /* Insert+Labels.swift */,
);
path = Commands;
@ -1125,6 +1147,7 @@
E2FE0F342D2B27E6002963B7 /* Blocks */ = {
isa = PBXGroup;
children = (
E2EC1FAF2DC0D7D600C41784 /* RouteBlock.swift */,
E2B482212D676BEB005C309D /* PhoneScreensBlock.swift */,
E2FE0F652D2C3B33002963B7 /* LabelsBlock.swift */,
E2FE0F5C2D2BD006002963B7 /* Types */,
@ -1306,6 +1329,7 @@
E29D313B2D04464A0051B7F4 /* LocalizedTagDetailView.swift in Sources */,
E2FE0F552D2BCFC4002963B7 /* ContentBlock.swift in Sources */,
E2A37D212CEA94EC0000979F /* Sequence+Sorted.swift in Sources */,
E2EC1FB02DC0D7DA00C41784 /* RouteBlock.swift in Sources */,
E29D31BA2D0DB5080051B7F4 /* LabelsCommand.swift in Sources */,
E2FE0EFC2D266D22002963B7 /* NavigationSettings.swift in Sources */,
E29D31692D0702700051B7F4 /* TextFileContentView.swift in Sources */,
@ -1395,6 +1419,7 @@
E20BCC9F2D53851400B8DBEB /* SelectableListItem.swift in Sources */,
E20BCCAB2D53B86900B8DBEB /* GenerationResultsIssueView.swift in Sources */,
E2B482092D5E7F4F005C309D /* WebsitePreviewSheet.swift in Sources */,
E2EC1FAD2DC0D2FA00C41784 /* RouteLocalization.swift in Sources */,
E22990262D0F582B009F8D77 /* FilePropertyView.swift in Sources */,
E2FD1D462D46428100B48627 /* PageIconView.swift in Sources */,
E2A37D252CEBD7A10000979F /* PageListView.swift in Sources */,
@ -1425,6 +1450,7 @@
E25DA5752D018B6100AEF16D /* FileDetailView.swift in Sources */,
E25DA50B2CFD988100AEF16D /* PageTagAssignmentView.swift in Sources */,
E2FD1D682D483CCF00B48627 /* Insert+Buttons.swift in Sources */,
E2EC1FB22DC0D8BD00C41784 /* RouteViewComponents.swift in Sources */,
E29D31A12D0C75CA0051B7F4 /* Content+Validation.swift in Sources */,
E29D316D2D07A5050051B7F4 /* PageGenerationResults.swift in Sources */,
E229903E2D0F8F02009F8D77 /* StringPropertyView.swift in Sources */,
@ -1519,6 +1545,7 @@
E25DA5712D01015400AEF16D /* GenerationContentView.swift in Sources */,
E29D316F2D0822770051B7F4 /* SettingsListView.swift in Sources */,
E2A21C0E2CB189DC0060935B /* Color+RGB.swift in Sources */,
E2EC1FAB2DC0C99600C41784 /* RouteViews.swift in Sources */,
E25DA5852D01C92700AEF16D /* CommandType.swift in Sources */,
E229903A2D0F7E48009F8D77 /* GenericPropertyView.swift in Sources */,
E29D31AA2D0CEE3F0051B7F4 /* AudioPlayer.swift in Sources */,
@ -1533,6 +1560,7 @@
E2FD1D602D47EEEF00B48627 /* LocalizedPostContentView.swift in Sources */,
E229903C2D0F8A7B009F8D77 /* OptionalTextFieldPropertyView.swift in Sources */,
E2FD1D582D477A9400B48627 /* InsertableCommand.swift in Sources */,
E2EC1FB42DC0FA8700C41784 /* Insert+Route.swift in Sources */,
E29D31222D0363FD0051B7F4 /* ContentButtons.swift in Sources */,
E2FE0F222D2A84A0002963B7 /* VideoCommand.swift in Sources */,
E2FE0F192D2723E3002963B7 /* ImageSet.swift in Sources */,

View File

@ -15,6 +15,8 @@ enum ContentBlock: String, CaseIterable {
case screens
case route
var processor: BlockProcessor.Type {
switch self {
case .audio: return AudioBlock.self
@ -24,6 +26,7 @@ enum ContentBlock: String, CaseIterable {
case .buttons: return ButtonsBlock.self
case .labels: return LabelsBlock.self
case .screens: return PhoneScreensBlock.self
case .route: return RouteBlock.self
}
}
}

View File

@ -0,0 +1,82 @@
struct RouteBlock: KeyedBlockProcessor {
enum Key: String {
case chartTitle
case components
case mapTitle
case image
case caption
case file
}
static let blockId: ContentBlock = .route
let content: Content
let results: PageGenerationResults
let language: ContentLanguage
init(content: Content, results: PageGenerationResults, language: ContentLanguage) {
self.content = content
self.results = results
self.language = language
}
private var thumbnailWidth: Int {
content.settings.pages.contentWidth
}
private var largeImageWidth: Int {
content.settings.pages.largeImageWidth
}
func process(_ arguments: [Key : String], markdown: Substring) -> String {
let rawComponents = arguments[.components] ?? "all"
guard let imageId = arguments[.image],
let fileId = arguments[.file],
let components = RouteViewComponents(rawValue: rawComponents) else {
invalid(markdown)
return ""
}
guard let image = content.image(imageId) else {
results.missing(file: imageId, source: "Route block")
return ""
}
guard let file = content.file(fileId) else {
results.missing(file: imageId, source: "Route block")
return ""
}
results.used(file: image)
results.require(file: file)
let thumbnail = image.imageSet(width: thumbnailWidth, height: thumbnailWidth, language: language)
results.require(imageSet: thumbnail)
let largeImage = image.imageSet(width: largeImageWidth, height: largeImageWidth, language: language)
results.require(imageSet: largeImage)
results.require(header: .routeJs)
let id = imageId.replacingOccurrences(of: ".", with: "-")
let views = RouteViews(
localization: language == .english ? .english : .german,
chartTitle: arguments[.chartTitle],
chartId: "chart-" + id,
components: components,
mapTitle: arguments[.mapTitle],
mapId: "map-" + id,
filePath: file.absoluteUrl,
imageId: "image-" + id,
thumbnail: thumbnail,
largeImage: largeImage,
caption: arguments[.caption])
results.require(footer: views.script)
return views.content
}
}

View File

@ -19,6 +19,9 @@ enum KnownHeaderElement {
case swiperJs
/// The Javascript to compute and animate route statistics
case routeJs
case style(String)
func header(content: Content) -> HeaderElement? {
@ -55,6 +58,10 @@ enum KnownHeaderElement {
if let swiperJs = content.settings.posts.swiperJsFile {
return .js(file: swiperJs, defer: true)
}
case .routeJs:
if let routeJs = content.settings.pages.routeJsFile {
return .js(file: routeJs, defer: false)
}
case .style(let code):
return .style(code)
}
@ -96,6 +103,8 @@ extension KnownHeaderElement: CustomStringConvertible {
return "swiper-css"
case .swiperJs:
return "swiper-js"
case .routeJs:
return "route-js"
case .style(let style):
return "style: " + style
}

View File

@ -25,6 +25,9 @@ final class GenerationResults: ObservableObject {
@Published
var requiredFiles: Set<FileResource> = []
@Published
var externalFiles: Set<FileResource> = []
@Published
var imagesToGenerate: Set<ImageVersion> = []
@ -113,6 +116,7 @@ final class GenerationResults: ObservableObject {
self.missingPages = []
self.externalLinks = []
self.requiredFiles = []
self.externalFiles = []
self.imagesToGenerate = []
self.invalidCommands = []
self.invalidBlocks = []
@ -143,6 +147,8 @@ final class GenerationResults: ObservableObject {
update { self.externalLinks = externalLinks }
let requiredFiles = cache.values.map { $0.requiredFiles }.union()
update { self.requiredFiles = requiredFiles }
let externalFiles = cache.values.map { $0.requiredFiles.filter { $0.isExternallyStored } }.union()
update { self.externalFiles = externalFiles }
let imagesToGenerate = cache.values.map { $0.imagesToGenerate }.union()
update { self.imagesToGenerate = imagesToGenerate }
let invalidCommands = cache.values.map { $0.invalidCommands.map { $0.markdown }}.union()
@ -195,11 +201,19 @@ final class GenerationResults: ObservableObject {
}
func require(file: FileResource) {
update { self.requiredFiles.insert(file) }
update {
self.requiredFiles.insert(file)
if file.isExternallyStored {
self.externalFiles.insert(file)
}
}
}
func require<S>(files: S) where S: Sequence, S.Element == FileResource {
update { self.requiredFiles.formUnion(files) }
update {
self.requiredFiles.formUnion(files)
self.externalFiles.formUnion(files.filter { $0.isExternallyStored })
}
}
func generate(_ image: ImageVersion) {

View File

@ -108,7 +108,11 @@ final class FileResource: Item, LocalizedItem {
}
func save(textContent: String) -> Bool {
content.storage.save(fileContent: textContent, for: id)
guard content.storage.save(fileContent: textContent, for: id) else {
return false
}
modifiedDate = .now
return true
}
func dataContent() -> Foundation.Data? {

View File

@ -90,6 +90,8 @@ enum FileType: String {
case txt
case gpx
// MARK: Model
case stl
@ -164,7 +166,7 @@ enum FileType: String {
return .video
case .mp3, .aac, .m4b, .m4a:
return .audio
case .json, .conf, .yaml, .txt:
case .json, .conf, .yaml, .txt, .gpx:
return .text
case .html, .cpp, .swift, .sh, .js, .css:
return .code

View File

@ -29,6 +29,9 @@ final class PageSettings: ObservableObject {
@Published
var manifestFile: FileResource?
@Published
var routeJsFile: FileResource?
@Published
var german: LocalizedPageSettings
@ -44,6 +47,7 @@ final class PageSettings: ObservableObject {
imageCompareJsFile: FileResource? = nil,
imageCompareCssFile: FileResource? = nil,
manifestFile: FileResource? = nil,
routeJsFile: FileResource? = nil,
german: LocalizedPageSettings,
english: LocalizedPageSettings) {
self.contentWidth = contentWidth
@ -55,6 +59,7 @@ final class PageSettings: ObservableObject {
self.imageCompareJsFile = imageCompareJsFile
self.imageCompareCssFile = imageCompareCssFile
self.manifestFile = manifestFile
self.routeJsFile = routeJsFile
self.german = german
self.english = english
}
@ -78,6 +83,9 @@ final class PageSettings: ObservableObject {
if manifestFile == file {
manifestFile = nil
}
if routeJsFile == file {
routeJsFile = nil
}
}
}
@ -96,6 +104,7 @@ extension PageSettings {
imageCompareJsFile: data.imageCompareJsFile.map(context.file),
imageCompareCssFile: data.imageCompareCssFile.map(context.file),
manifestFile: data.manifestFile.map(context.file),
routeJsFile: data.routeJsFile.map(context.file),
german: .init(data: data.german),
english: .init(data: data.english))
}
@ -110,6 +119,7 @@ extension PageSettings {
imageCompareJsFile: imageCompareJsFile?.id,
imageCompareCssFile: imageCompareCssFile?.id,
manifestFile: manifestFile?.id,
routeJsFile: routeJsFile?.id,
german: german.data,
english: english.data)
}
@ -124,6 +134,7 @@ extension PageSettings {
let imageCompareJsFile: String?
let imageCompareCssFile: String?
let manifestFile: String?
let routeJsFile: String?
let german: LocalizedPageSettings.Data
let english: LocalizedPageSettings.Data
}

View File

@ -19,9 +19,41 @@ struct PageImage: HtmlProducer {
/// The optional caption text below the fullscreen image
let caption: String?
/// The id of the image container
let id: String?
/// Additional class names for the image container
let className: String?
/// Additional content for the image container
let imageContent: String?
init(imageId: String, thumbnail: ImageSet, largeImage: ImageSet, caption: String?, id: String? = nil, className: String? = nil, imageContent: String? = nil) {
self.imageId = imageId
self.thumbnail = thumbnail
self.largeImage = largeImage
self.caption = caption
self.id = id
self.className = className
self.imageContent = imageContent
}
var idString: String {
guard let id else { return "" }
return " id='\(id)'"
}
var classString: String {
guard let className else { return "" }
return " \(className)"
}
func populate(_ result: inout String) {
result += "<div class='content-image' onclick=\"document.getElementById('\(imageId)').classList.add('active')\">"
result += "<div\(idString) class='content-image\(classString)' onclick=\"document.getElementById('\(imageId)').classList.add('active')\">"
result += thumbnail.content
if let imageContent {
result += imageContent
}
result += "</div>"
result += "<div id='\(imageId)' class='fullscreen-image' onclick=\"document.getElementById('\(imageId)').classList.remove('active')\">"
result += largeImage.content

View File

@ -0,0 +1,50 @@
struct RouteLocalization {
let elevation: String
let speed: String
let pace: String
let heartRate: String
let fallback: String
let hourUnit: String
let duration: String
let time: String
let distance: String
let loadFail: String
}
extension RouteLocalization {
static let german: RouteLocalization = .init(
elevation: "Höhe",
speed: "Geschw.",
pace: "Pace",
heartRate: "Herzfrequenz",
fallback: "Zur Anzeige der Statistiken wird JavaScript und Unterstützung für HTML5 Canvas benötigt.",
hourUnit: "Std",
duration: "Dauer",
time: "Zeit",
distance: "Distanz",
loadFail: "Die Statistiken konnten nicht geladen werden")
static let english: RouteLocalization = .init(
elevation: "Elevation",
speed: "Speed",
pace: "Pace",
heartRate: "Heart Rate",
fallback: "Javascript and HTML5 Canvas Support are required to display statistics",
hourUnit: "h",
duration: "Duration",
time: "Time",
distance: "Distance",
loadFail: "The statistics could not be loaded")
}

View File

@ -0,0 +1,7 @@
enum RouteViewComponents: String {
case onlyElevation = "elevation"
case all = "all"
case withoutHeartRate = "no-hr"
}

View File

@ -0,0 +1,98 @@
struct RouteViews: HtmlProducer {
let localization: RouteLocalization
/// The HTML id attribute used to enable fullscreen images
let map: PageImage
let components: RouteViewComponents
let mapId: String
let mapTitle: String?
let filePath: String
let chartId: String
let chartTitle: String?
init(localization: RouteLocalization,
chartTitle: String?,
chartId: String,
components: RouteViewComponents,
mapTitle: String?,
mapId: String,
filePath: String,
imageId: String,
thumbnail: ImageSet,
largeImage: ImageSet,
caption: String?
) {
self.localization = localization
self.components = components
self.mapId = mapId
self.filePath = filePath
self.map = PageImage(
imageId: imageId,
thumbnail: thumbnail,
largeImage: largeImage,
caption: caption,
id: mapId,
className: "map-container",
imageContent: "<div class='marker'></div>")
self.chartTitle = chartTitle
self.chartId = chartId
self.mapTitle = mapTitle
}
var pickerHiddenText: String {
guard components == .onlyElevation else { return "" }
return " style='display:none'"
}
func populate(_ result: inout String) {
if let mapTitle {
result += "<h2>\(mapTitle)</h2>"
}
map.populate(&result)
if let chartTitle {
result += "<h2>\(chartTitle)</h2>"
}
result += "<div id='\(chartId)' class='charts'>"
result += "<div class='picker y-picker'\(pickerHiddenText)>"
result += "<button data-type='elevation' unit='m' class='active'>\(localization.elevation)</button>"
result += "<button data-type='speed' unit='km/h'>\(localization.speed)</button>"
result += "<button data-type='pace' unit='min/km'>\(localization.pace)</button>"
if components == .all {
result += "<button data-type='hr' unit='bpm'>\(localization.heartRate)</button>"
}
result += "</div>"
result += "<div class='graph'>"
result += "<canvas>"
result += "<div class='fallback'>\(localization.fallback)</div>"
result += "</canvas>"
result += "<div class='line'></div>"
result += "<div class='tooltip'></div>"
result += "<div class='load-error'>\(localization.loadFail)</div>"
result += "</div>"
result += "<div class='picker x-picker'\(pickerHiddenText)>"
result += "<button data-type='distance' unit='km' class='active'>\(localization.distance)</button>"
result += "<button data-type='duration' unit='\(localization.hourUnit)'>\(localization.duration)</button>"
result += "<button data-type='time' unit=''>\(localization.time)</button>"
result += "</div>"
result += "</div>"
}
var script: String {
"""
<script>
window.addEventListener('DOMContentLoaded', () => {
initializeGraphs(document, '\(mapId)', '\(chartId)', '\(filePath)');
});
</script>
"""
}
}

View File

@ -41,7 +41,7 @@ struct FileContentView: View {
.foregroundStyle(.secondary)
case .text, .code:
TextFileContentView(file: file)
.id(file.id)
.id(file.id + file.modifiedDate.description)
case .video:
VStack {
if let image = file.imageToDisplay {

View File

@ -11,6 +11,9 @@ struct TextFileContentView: View {
@State
private var loadedFile: String?
@State
private var loadedFileDate: Date = .now
var body: some View {
VStack {
if fileContent != "" {
@ -47,6 +50,7 @@ struct TextFileContentView: View {
private func reload() {
fileContent = file.textContent()
loadedFile = file.id
loadedFileDate = file.modifiedDate
print("Loaded content of file \(file.id)")
}
@ -57,6 +61,12 @@ struct TextFileContentView: View {
}
guard loadedFile == file.id else {
print("[ERROR] Text File View: Not saving since file changed")
reload()
return
}
guard loadedFileDate == file.modifiedDate else {
print("Text File View: Not saving changed file \(file.id)")
reload()
return
}
guard fileContent != "" else {
@ -67,6 +77,7 @@ struct TextFileContentView: View {
print("[ERROR] Text File View: Failed to save file \(file.id)")
return
}
loadedFileDate = file.modifiedDate
print("Text File View: Saved file \(file.id)")
}
}

View File

@ -42,6 +42,10 @@ struct GenerationContentView: View {
}
Text(content.generationStatus)
.padding(.vertical, 5)
GenerationStringIssuesView(
text: "output files",
statusWhenNonEmpty: .nominal,
items: $content.results.outputFiles)
GenerationResultsIssueView(
text: "\(content.results.imagesToGenerate.count) images",
status: .nominal,
@ -58,10 +62,18 @@ struct GenerationContentView: View {
text: "required files",
statusWhenNonEmpty: .nominal,
items: $content.results.requiredFiles) { $0.id }
GenerationStringIssuesView(
text: "external files",
statusWhenNonEmpty: .nominal,
items: $content.results.externalFiles) { $0.id }
GenerationStringIssuesView(
text: "empty pages",
statusWhenNonEmpty: .warning,
items: $content.results.emptyPages) { "\($0.pageId) (\($0.language))" }
GenerationStringIssuesView(
text: "additional output files",
statusWhenNonEmpty: .warning,
items: $content.results.unusedFilesInOutput)
GenerationStringIssuesView(
text: "inaccessible files",
items: $content.results.inaccessibleFiles) { $0.id }
@ -89,14 +101,6 @@ struct GenerationContentView: View {
GenerationStringIssuesView(
text: "invalid blocks",
items: $content.results.invalidBlocks)
GenerationStringIssuesView(
text: "output files",
statusWhenNonEmpty: .nominal,
items: $content.results.outputFiles)
GenerationStringIssuesView(
text: "additional output files",
statusWhenNonEmpty: .warning,
items: $content.results.unusedFilesInOutput)
GenerationStringIssuesView(
text: "warnings",
statusWhenNonEmpty: .warning,

View File

@ -0,0 +1,111 @@
import SwiftUI
import SFSafeSymbols
struct InsertableRoute: View, InsertableCommandView {
final class Model: ObservableObject, InsertableCommandModel {
@Published
var caption: String?
@Published
var chartTitle: String?
@Published
var components: RouteViewComponents = .all
@Published
var mapTitle: String?
@Published
var selectedImage: FileResource?
@Published
var dataFile: FileResource?
var isReady: Bool {
selectedImage != nil && dataFile != nil
}
init() {
}
var command: String? {
guard let selectedImage,
let dataFile else {
return nil
}
var result = ["```route"]
result.append("\(RouteBlock.Key.image.rawValue): \(selectedImage.id)")
result.append("\(RouteBlock.Key.file.rawValue): \(dataFile.id)")
result.append("\(RouteBlock.Key.components.rawValue): \(components.rawValue)")
if let caption {
result.append("\(RouteBlock.Key.caption.rawValue): \(caption)")
}
if let chartTitle {
result.append("\(RouteBlock.Key.chartTitle.rawValue): \(chartTitle)")
}
if let mapTitle {
result.append("\(RouteBlock.Key.mapTitle.rawValue): \(mapTitle)")
}
result.append("\n```")
return result.joined(separator: "\n")
}
}
static let title = "Route"
static let sheetTitle = "Insert route map and statistics"
static let icon: SFSymbol = .map
@ObservedObject
private var model: Model
init(model: Model) {
self.model = model
}
var body: some View {
VStack {
FilePropertyView(
title: "Map Image",
footer: "Select the image to insert as the map",
selectedFile: $model.selectedImage,
allowedType: .image)
FilePropertyView(
title: "Data File",
footer: "Select the file containing the statistics",
selectedFile: $model.dataFile,
allowedType: .text)
OptionalStringPropertyView(
title: "Map Title",
text: $model.mapTitle,
prompt: "Map title",
footer: "The title to show above the map image")
OptionalStringPropertyView(
title: "Map Image Caption",
text: $model.caption,
prompt: "Image Caption",
footer: "The caption to show below the fullscreen map")
OptionalStringPropertyView(
title: "Chart Title",
text: $model.chartTitle,
prompt: "Title",
footer: "The title to show above the statistics")
Picker(selection: $model.components) {
Text("All").tag(RouteViewComponents.all)
Text("Only elevation").tag(RouteViewComponents.withoutHeartRate)
Text("No heart rate").tag(RouteViewComponents.withoutHeartRate)
} label: {
Text("")
}
.pickerStyle(.segmented)
}
}
}
#Preview {
InsertableRoute(model: .init())
}

View File

@ -10,6 +10,7 @@ struct InsertableItemsView: View {
InsertableView<InsertableLabels>()
InsertableView<InsertableButtons>()
InsertableView<InsertableLink>()
InsertableView<InsertableRoute>()
}
}
}

View File

@ -62,6 +62,11 @@ struct PageSettingsDetailView: View {
footer: "The manifest file with the properties of the website when used as a progressive web app",
selectedFile: $pageSettings.manifestFile)
FilePropertyView(
title: "Route Statistics Javascript File",
footer: "The JavaScript file containing the logic to compute and animate statistics about workout routes",
selectedFile: $pageSettings.routeJsFile)
LocalizedPageSettingsView(settings: pageSettings.localized(in: language))
.id(language)
}