From b7923c9091824c8c1698f12f83def4bf10d70182 Mon Sep 17 00:00:00 2001 From: Joshua Ramon Enslin Date: Tue, 4 Mar 2025 12:05:31 +0100 Subject: [PATCH] Finish first version of WebUI Close #10 --- .gitmodules | 3 + src/cli/cli.go | 2 + src/webdavupload/Upload.go | 37 +++++++++ src/webui/assets/md-uploader.css | 60 ++++++++++---- src/webui/assets/md-uploader.js | 117 +++++++++++++++++++++------ src/webui/serveApiGetTranslations.go | 32 ++++++++ src/webui/serveApiTriggerUpload.go | 3 + src/webui/server.go | 1 + src/webui/translation-json | 1 + 9 files changed, 216 insertions(+), 40 deletions(-) create mode 100644 .gitmodules create mode 100644 src/webui/serveApiGetTranslations.go create mode 160000 src/webui/translation-json diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..c061489 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "src/webui/translation-json"] + path = src/webui/translation-json + url = https://gitea.armuli.eu/museum-digital/translation-json.git diff --git a/src/cli/cli.go b/src/cli/cli.go index f4733aa..667ba5e 100644 --- a/src/cli/cli.go +++ b/src/cli/cli.go @@ -398,9 +398,11 @@ func HandleUpload() { // Upload the files if len(uploadableMetadata) > 0 { webdavupload.UploadMetadataFiles(c, os.Stdout, uploadableMetadata) + webdavupload.BatchUnlink(os.Stdout, uploadableMetadata) } if len(uploadableMedia) > 0 { webdavupload.UploadMediaFiles(c, os.Stdout, uploadableMedia) + webdavupload.BatchUnlink(os.Stdout, uploadableMedia) } // Generate the import config. diff --git a/src/webdavupload/Upload.go b/src/webdavupload/Upload.go index b4de888..83532c3 100644 --- a/src/webdavupload/Upload.go +++ b/src/webdavupload/Upload.go @@ -93,3 +93,40 @@ func UploadMediaFiles(c *gowebdav.Client, w io.Writer, files []string) { uploadFiles(c, w, files, "IMPORT_IMG", "Uploading media files") } + +// Removes a list of files. +func BatchUnlink(w io.Writer, files []string) { + + _, wImplementsHttpFlusher := interface{}(w).(http.Flusher) + + maxConcTasks := min(10, runtime.NumCPU()) + + // Set a semaphore to restrict the number of concurrent upload tasks. + semaphore := make(chan struct{}, maxConcTasks) + wg := &sync.WaitGroup{} + + for _, f := range(files) { + + semaphore <- struct{}{} // acquire + wg.Add(1) + + go func() { + + defer wg.Done() + err := os.Remove(f) + if err != nil { + panic("Failed to delete file " + f) + } + fmt.Fprintf(w, "Delete file %s\n", f) + <-semaphore // release + + }() + + } + + wg.Wait() + if wImplementsHttpFlusher == true { + w.(http.Flusher).Flush() + } + +} diff --git a/src/webui/assets/md-uploader.css b/src/webui/assets/md-uploader.css index c4d3b71..3f7a5ee 100644 --- a/src/webui/assets/md-uploader.css +++ b/src/webui/assets/md-uploader.css @@ -1,9 +1,6 @@ :root { --color-bg: #FFF; --color-bg2: #F5F5F5; - --color-bg-transparent: rgba(255,255,255,.4); - --color-bg-transparent-stronger: rgba(255,255,255,.7); - --color-bg-transparent-dark: rgba(0,0,0,.4); --color-fg: #000; --color-fg2: #212121; --color-bg-dark: #212121; @@ -24,9 +21,6 @@ :root { --color-bg: #000; --color-bg2: #2685AF; - --color-bg-transparent: rgba(0,0,0,.4); - --color-bg-transparent-stronger: rgba(0,0,0,.7); - --color-bg-transparent-dark: rgba(0,0,0,.4); --color-fg: #FFF; --color-fg2: #D6D6D6; --color-bg-dark: #212121; @@ -52,18 +46,32 @@ html { body { margin: 0; padding: 0; } -header { display: block; background: var(--color-bg); font-size: 1.5em; padding: .4em 2rem; - box-shadow: 0 4px 2px -2px var(--color-borders); } +header { display: grid; grid-template-columns: auto auto 100fr auto; + align-items: center; + background: var(--color-bg); font-size: 1.5em; padding: .4em 2rem; + box-shadow: 0 4px 2px -2px var(--color-borders); } header * { margin: 0; padding: 0; vertical-align: middle; } header img { height: 1em; } +header #actions { grid-column: 4; font-size: .85rem; font-weight: bold; } +header #actions > * { padding: .2em .5em; + border-radius: .2em; color: var(--color-fg2); transition: .2s; } +header #actions > :hover { background: var(--color-accent-hover); color: var(--color-bg); } + h1 { display: inline-block; margin-left: 1em; font-size: .8em; } -#wrap { display: grid; grid-template-columns: 450px 1fr; margin: 1em 2rem; grid-gap: 2em; } +#wrap { display: grid; + grid-template-columns: 450px 1fr; grid-template-rows: auto auto; + margin: 1em 2rem; grid-gap: 2em; } #wrap > * { vertical-align: top; } main, -aside { background: var(--color-bg); border-radius: 1px solid var(--color-borders); box-shadow: 0 0 6px var(--color-borders); border-radius: 2px; } +aside, +.sec-aside { background: var(--color-bg); border-radius: 1px solid var(--color-borders); box-shadow: 0 0 6px var(--color-borders); border-radius: 2px; } + +aside, +.sec-aside { grid-column: 1; } +main { grid-column: 2; grid-row: 1 / span 2; } h2 { margin: 0; padding: .8rem; font-size: 1.4em; border-bottom: .15em solid var(--color-borders); } @@ -72,18 +80,22 @@ h3 { margin: 0; padding: .8rem; border-bottom: .15em solid var(--color-borders); input { background: var(--color-bg); color: var(--color-fg); } aside .label-input { display: grid; - grid-template-columns: 150px 2fr; border-bottom: 1px solid var(--color-borders); } + grid-template-columns: 150px auto 2em; border-bottom: 1px solid var(--color-borders); } aside .label-input:hover { border-color: var(--color-accent); } aside .label-input > * { display: inline-block; font-size: .9em; padding: .5rem .8rem; border: 0; outline: 0; } aside label { font-weight: bold; color: var(--color-fg-medium); } -aside .label-input3 { grid-template-columns: 150px auto 2em; } -aside .label-input3 button { background: none; transition: background .2s; } -aside .label-input3 button:hover { background: var(--color-accent); } +aside .label-input button { background: none; transition: background .2s; } +aside .label-input button:hover { background: var(--color-accent); } -select { background: var(--color-bg); color: var(--color-fg); } +.icon-help:before { content: " ? "; font-weight: bold; font-size: .8em; + text-align: center; color: var(--color-fg2); transition: background .2s; } +.icon-help { transition: background .2s; } +.icon-help:hover { background: var(--color-accent); } + +select { width: 100%; background: var(--color-bg); color: var(--color-fg); } .updateB { display: block; width: calc(100% - 1em); background: var(--color-bg); margin: .5em; padding: .5em; @@ -98,6 +110,11 @@ select { background: var(--color-bg); color: var(--color-fg); } border-bottom: 1px solid var(--color-borders); transition: border .2s, background .2s; } .upload-file-list > span:hover { background: var(--color-bg2); border-color: var(--color-accent-hover); } +.upload-file-list:empty { display: block; position: relative; height: 5em; } +.upload-file-list:empty:before { content: " - - - - - "; + position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); + font-weight: bold; font-size: 3em; color: var(--color-borders); } + main { position: relative; } main h3 { position: sticky; top: 0; background: var(--color-bg); } @@ -107,6 +124,17 @@ main h3 { position: sticky; top: 0; background: var(--color-bg); } border: 2px solid var(--color-accent-hover); border-bottom-width: 0; box-shadow: 0 0 6px var(--color-borders); z-index: 10; animation: slide-up .4s ease-out; } +.transfer-overlay h3 { border-bottom: none; } +.transfer-overlay-hl { display: grid; grid-template-columns: auto 1fr auto; + padding-right: .8em; + align-items: center; border-bottom: .15em solid var(--color-borders); } +.transfer-overlay-hl .overlay-close { display: inline-block; + color: var(--color-fg2); + padding: .5em; border-radius: 2px; + grid-column: 3; + transition: .2s; } +.transfer-overlay-hl .overlay-close:hover { background: var(--color-accent-hover); color: var(--color-bg); } +.transfer-overlay-hl .overlay-close:before { display: inline-block; content: " x ";} .transfer-msgs { font-size: .9em; } .transfer-msgs > * { display: block; border-bottom: 1px solid var(--color-borders); transition: .2s; } @@ -119,6 +147,8 @@ main h3 { position: sticky; top: 0; background: var(--color-bg); } } iframe { display: block; width: 100%; border: none; height: 60vh; } +.parser-desc { display: block; padding: 0 .8rem; + font-size: .85em; white-space: pre-wrap; } /*** * Slider diff --git a/src/webui/assets/md-uploader.js b/src/webui/assets/md-uploader.js index 101c7c5..26e4abe 100644 --- a/src/webui/assets/md-uploader.js +++ b/src/webui/assets/md-uploader.js @@ -18,7 +18,7 @@ class App { return elem; } - generateLabelInput(id, labelText, type, value) { + generateLabelInput(id, labelText, explanationText, type, value) { const output = document.createElement("div"); output.classList.add("label-input"); @@ -34,33 +34,38 @@ class App { input.value = value; output.appendChild(input); + const help = document.createElement("span"); + help.classList.add("icon-help"); + help.title = explanationText; + output.appendChild(help); + return {'wrap': output, 'input': input}; } async generateSidebar() { - this.aside.appendChild(this.generateTextElement("h2", "Settings")); // TODO + this.aside.appendChild(this.generateTextElement("h2", this.tls.settings)); // TODO // Set up fields const instanceLinkArea = this.generateLabelInput("instance-link", - "Instance link", "url", this.config.settings.instance); + this.tls.instance_link, this.tls.instance_link_explica, "url", this.config.settings.instance); this.aside.appendChild(instanceLinkArea.wrap); const usernameArea = this.generateLabelInput("username", - "Username", "text", this.config.settings.username); + this.tls.username, this.tls.username_explica, "text", this.config.settings.username); this.aside.appendChild(usernameArea.wrap); const mailArea = this.generateLabelInput("mail", - "Mail", "email", this.config.settings.mail); + this.tls.mail, this.tls.mail_explica, "email", this.config.settings.mail); this.aside.appendChild(mailArea.wrap); const institutionIdArea = this.generateLabelInput("institutionId", - "Institution ID", "number", this.config.settings.institution_id); + this.tls.institution_id, this.tls.institution_id_explica, "number", this.config.settings.institution_id); this.aside.appendChild(institutionIdArea.wrap); const tokenArea = this.generateLabelInput("token", - "Token", "text", this.config.settings.token); + this.tls.token, this.tls.token_explica, "text", this.config.settings.token); this.aside.appendChild(tokenArea.wrap); const parserDiv = document.createElement("div"); @@ -68,7 +73,7 @@ class App { const parserLabel = document.createElement("label"); parserLabel.setAttribute("for", "parserSelect"); - parserLabel.textContent = "Parser"; + parserLabel.textContent = this.tls.parser; parserDiv.appendChild(parserLabel); const parserSelect = document.createElement("select"); parserSelect.id = "parserSelect"; @@ -84,17 +89,23 @@ class App { } parserDiv.appendChild(parserSelect); + + const parserHelp = document.createElement("span"); + parserHelp.classList.add("icon-help"); + parserHelp.title = this.tls.parser_explica; + parserDiv.appendChild(parserHelp); + this.aside.appendChild(parserDiv); const uploadFolderArea = this.generateLabelInput("upload-folder", - "Upload directory", "text", this.config.settings.upload_directory); + this.tls.upload_directory, this.tls.upload_directory_explica, "text", this.config.settings.upload_directory); this.aside.appendChild(uploadFolderArea.wrap); const visibleDivOuter = document.createElement("div"); visibleDivOuter.classList.add("label-input"); const visibleLabel = document.createElement("label"); visibleLabel.setAttribute("for", "visible"); - visibleLabel.textContent = "Publish immediately?"; + visibleLabel.textContent = this.tls.publish_immediately; visibleDivOuter.appendChild(visibleLabel); const visibleDiv = document.createElement("div"); @@ -119,32 +130,40 @@ class App { visibleDiv.appendChild(visibleSwitch); visibleDivOuter.appendChild(visibleDiv); + + const visibleHelp = document.createElement("span"); + visibleHelp.classList.add("icon-help"); + visibleHelp.title = this.tls.publish_immediately_explica; + visibleDivOuter.appendChild(visibleHelp); + this.aside.appendChild(visibleDivOuter); // Additional settings - this.aside.appendChild(this.generateTextElement("h3", "Additional settings")); + this.aside.appendChild(this.generateTextElement("h3", this.tls.additional_settings)); const addSettingsArea = document.createElement("div"); + const app = this; function generateAdditionalSettingsField(key, value) { const line = document.createElement("div"); - line.classList.add("label-input", "label-input3"); + line.classList.add("label-input"); const newKey = document.createElement("input"); - newKey.placeholder = "Key (aspect to set)"; + newKey.placeholder = app.tls.add_settings_key; newKey.required = "required"; newKey.value = key; line.appendChild(newKey); const newVal = document.createElement("input"); - newVal.placeholder = "Value (what to set it to?)"; + newVal.placeholder = app.tls.add_settings_value; newVal.required = "required"; newVal.value = value; line.appendChild(newVal); const removeB = document.createElement("button"); removeB.textContent = " x "; + removeB.title = app.tls.remove; line.appendChild(removeB); removeB.addEventListener('click', function() { @@ -164,21 +183,22 @@ class App { this.aside.appendChild(addSettingsArea); const newSettingLine = document.createElement("form"); - newSettingLine.classList.add("label-input", "label-input3"); + newSettingLine.classList.add("label-input"); const newKey = document.createElement("input"); - newKey.placeholder = "Key (aspect to set)"; + newKey.placeholder = this.tls.add_settings_key; newKey.required = "required"; newSettingLine.appendChild(newKey); const newVal = document.createElement("input"); - newVal.placeholder = "Value (what to set it to?)"; + newVal.placeholder = this.tls.add_settings_value; newVal.required = "required"; newSettingLine.appendChild(newVal); const newSubmitB = document.createElement("button"); newSubmitB.type = "submit"; newSubmitB.textContent = " > "; + newSubmitB.title = app.tls.add; newSettingLine.appendChild(newSubmitB); newSettingLine.addEventListener('submit', function(e) { @@ -194,7 +214,7 @@ class App { this.aside.appendChild(newSettingLine) const updateB = document.createElement("button"); - updateB.textContent = "Save / Update settings"; + updateB.textContent = this.tls.save_update; updateB.classList.add("updateB"); this.aside.appendChild(updateB); @@ -245,12 +265,12 @@ class App { generateMain() { - this.main.appendChild(this.generateTextElement("h2", "Uploadable files")); + this.main.appendChild(this.generateTextElement("h2", this.tls.uploadable_files)); // Metadata const metadataSec = document.createElement("section"); - metadataSec.appendChild(this.generateTextElement("h3", "Metadata files")); + metadataSec.appendChild(this.generateTextElement("h3", this.tls.metadata_files)); const metadataList = document.createElement("div"); metadataList.classList.add("upload-file-list"); @@ -268,7 +288,7 @@ class App { // Media const mediaSec = document.createElement("section"); - mediaSec.appendChild(this.generateTextElement("h3", "Media files")); + mediaSec.appendChild(this.generateTextElement("h3", this.tls.media_files)); const mediaList = document.createElement("div"); mediaList.classList.add("upload-file-list"); @@ -282,7 +302,6 @@ class App { mediaSec.appendChild(mediaList); this.main.appendChild(mediaSec); - } generateActionsMenu() { @@ -292,7 +311,7 @@ class App { } const uploadTrigger = document.createElement("span"); - uploadTrigger.textContent = "Upload"; + uploadTrigger.textContent = this.tls.upload; this.actionsArea.appendChild(uploadTrigger); const app = this; @@ -302,7 +321,16 @@ class App { transferOverlay.id = "transfer-overlay"; transferOverlay.classList.add("transfer-overlay"); const hl = document.createElement("div"); - hl.appendChild(app.generateTextElement("h3", "Transfer")); + hl.classList.add("transfer-overlay-hl"); + hl.appendChild(app.generateTextElement("h3", app.tls.upload)); + + const closeB = document.createElement("span"); + closeB.classList.add("overlay-close"); + hl.appendChild(closeB); + + closeB.addEventListener('click', function() { location.reload(); }); + // closeB.addEventListener('click', function() { transferOverlay.parentElement.removeChild(transferOverlay); }); + transferOverlay.appendChild(hl); const transferMsgs = document.createElement("div"); @@ -320,6 +348,32 @@ class App { } + // Provides a sidebar entry for describing the parser + generateParserDesc() { + + for (const parser of this.parserList) { + if (parser.title == this.config.settings.parser) { + + const aside = document.createElement("div"); + aside.classList.add("sec-aside"); + + aside.appendChild(this.generateTextElement("h3", this.tls.parser)); + const div = document.createElement("div"); + div.classList.add("parser-desc"); + + const desc = document.createElement("p"); + desc.textContent = parser.comment; + div.appendChild(desc); + + aside.appendChild(div); + + this.wrap.appendChild(aside); + break; + } + } + + } + render() { if (this.config.setup_required === true) { @@ -330,6 +384,9 @@ class App { this.generateSidebar(); this.generateMain(); this.generateActionsMenu(); + + this.generateParserDesc(); + } } @@ -341,7 +398,7 @@ class App { let done = 0; function wrapFinish() { - if (done === 3) { + if (done === 4) { app.render(); } } @@ -367,12 +424,22 @@ class App { done++; wrapFinish(); }); + window.fetch('/get-translations?lang=' + encodeURIComponent(this.lang), {method: 'GET', cache: 'no-cache', credentials: 'same-origin'}) + .then(function(response) { return response.json(); }) + .then(function(elements) { + app.tls = elements.webdav_uploader; + done++; + wrapFinish(); + }); } constructor() { this.wrap = document.getElementById("wrap"); this.lang = navigator.language.substring(0, 2) + if (["de", "en"].includes(this.lang) === false) { + this.lang = "en"; + } this.aside = document.createElement("aside"); this.main = document.createElement("main"); diff --git a/src/webui/serveApiGetTranslations.go b/src/webui/serveApiGetTranslations.go new file mode 100644 index 0000000..05d6d7f --- /dev/null +++ b/src/webui/serveApiGetTranslations.go @@ -0,0 +1,32 @@ +package webui + +import ( + _ "embed" + "fmt" + "net/http" +) + +type tlRaw struct { + webdav_uploader map[string]string `json:"webdav_uploader"` +} + +//go:embed translation-json/webdav-uploader/de/webdav_uploader.json +var rawTlsDe string +//go:embed translation-json/webdav-uploader/en/webdav_uploader.json +var rawTlsEn string + +// Retrieves translation variables in the given language. +func serveApiGetTranslations(w http.ResponseWriter, r *http.Request) { + + var rawTls string + if r.URL.Query().Get("lang") == "de" { + rawTls = rawTlsDe + } else { + rawTls = rawTlsEn + } + + setHeadersForJson(w) + + fmt.Fprint(w, rawTls) + +} diff --git a/src/webui/serveApiTriggerUpload.go b/src/webui/serveApiTriggerUpload.go index a46e96b..7e51233 100644 --- a/src/webui/serveApiTriggerUpload.go +++ b/src/webui/serveApiTriggerUpload.go @@ -39,14 +39,17 @@ func serveApiTriggerUpload(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, "Uploading metadata files\n") w.(http.Flusher).Flush() webdavupload.UploadMetadataFiles(c, w, uploadableMetadata) + webdavupload.BatchUnlink(w, uploadableMetadata) } if len(uploadableMedia) > 0 { fmt.Fprint(w, "Uploading media files\n") webdavupload.UploadMediaFiles(c, w, uploadableMedia) + webdavupload.BatchUnlink(w, uploadableMedia) } fmt.Fprint(w, "Generating and uploading import configuration\n") webdavupload.SetImportConfigToTheRemote(c, config) fmt.Fprint(w, "DONE\n") + w.(http.Flusher).Flush() } diff --git a/src/webui/server.go b/src/webui/server.go index 1d4eb55..e74d4b9 100644 --- a/src/webui/server.go +++ b/src/webui/server.go @@ -18,6 +18,7 @@ func Run() { // Bind callable URLs http.HandleFunc("/", serveAppShell) // Serve app shell http.HandleFunc("/get-settings", serveApiGetSettings) // API for listing current settings + http.HandleFunc("/get-translations", serveApiGetTranslations) // API for listing current settings http.HandleFunc("/list-uploadable", serveApiListUploadable) // API to list uploadable files http.HandleFunc("/list-parsers", serveApiListParsers) // API to list available parsers. http.HandleFunc("/update-settings", serveApiUpdateSettings) // API for updating settings diff --git a/src/webui/translation-json b/src/webui/translation-json new file mode 160000 index 0000000..6803016 --- /dev/null +++ b/src/webui/translation-json @@ -0,0 +1 @@ +Subproject commit 6803016b709dcc0bf92ddfc29c6bf7bd9bd29cf7