From a8a6292d2b07479d28149f90b54de5171623a8fd Mon Sep 17 00:00:00 2001 From: Joshua Ramon Enslin Date: Sun, 2 Mar 2025 22:59:51 +0100 Subject: [PATCH] Begin adding WebUI See #10 --- main.go | 3 + src/webui/assets/index.htm | 24 ++ src/webui/assets/logo-md-code-black.svg | 423 ++++++++++++++++++++++++ src/webui/assets/md-uploader.css | 97 ++++++ src/webui/assets/md-uploader.js | 183 ++++++++++ src/webui/headers.go | 46 +++ src/webui/serveApiGetSettings.go | 27 ++ src/webui/serveApiListParsers.go | 22 ++ src/webui/serveApiListUploadable.go | 36 ++ src/webui/serveApiUpdateSettings.go | 59 ++++ src/webui/serveAppShell.go | 16 + src/webui/serveCss.go | 15 + src/webui/serveJs.go | 16 + src/webui/serveLogo.go | 16 + src/webui/server.go | 39 +++ 15 files changed, 1022 insertions(+) create mode 100644 src/webui/assets/index.htm create mode 100644 src/webui/assets/logo-md-code-black.svg create mode 100644 src/webui/assets/md-uploader.css create mode 100644 src/webui/assets/md-uploader.js create mode 100644 src/webui/headers.go create mode 100644 src/webui/serveApiGetSettings.go create mode 100644 src/webui/serveApiListParsers.go create mode 100644 src/webui/serveApiListUploadable.go create mode 100644 src/webui/serveApiUpdateSettings.go create mode 100644 src/webui/serveAppShell.go create mode 100644 src/webui/serveCss.go create mode 100644 src/webui/serveJs.go create mode 100644 src/webui/serveLogo.go create mode 100644 src/webui/server.go diff --git a/main.go b/main.go index df22618..4ba9681 100644 --- a/main.go +++ b/main.go @@ -4,6 +4,7 @@ import ( "slices" "os" "gitea.armuli.eu/museum-digital/museum-digital-webdav-uploader/src/cli" + "gitea.armuli.eu/museum-digital/museum-digital-webdav-uploader/src/webui" ) // Attempts to connect to WebDAV server, verifying that the authentication data @@ -54,6 +55,8 @@ func main() { cli.ListLocalMetadata() } else if slices.Contains(os.Args, "--local-list-media") { cli.ListLocalMedia() + } else if slices.Contains(os.Args, "--webui") { + webui.Run() } else { // cli.RunManualSetup() } diff --git a/src/webui/assets/index.htm b/src/webui/assets/index.htm new file mode 100644 index 0000000..cf99952 --- /dev/null +++ b/src/webui/assets/index.htm @@ -0,0 +1,24 @@ + + + + museum-digital uploader + + + + + + + + + + + +
+ +

museum-digital:uploader

+
+
+ + + + diff --git a/src/webui/assets/logo-md-code-black.svg b/src/webui/assets/logo-md-code-black.svg new file mode 100644 index 0000000..3d530ea --- /dev/null +++ b/src/webui/assets/logo-md-code-black.svg @@ -0,0 +1,423 @@ + + + + + General logo for museum-digital (black background) + + + + + + image/svg+xml + + + General logo for museum-digital (black background) + 2019 + + + museum-digital + + + + + + + + + + + + + + +   + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/webui/assets/md-uploader.css b/src/webui/assets/md-uploader.css new file mode 100644 index 0000000..70ebc62 --- /dev/null +++ b/src/webui/assets/md-uploader.css @@ -0,0 +1,97 @@ +: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; + --color-fg-medium: #484848; + --color-borders: #D6D6D6; + + --color-accent: #546E7A; + --color-accent-hover: #455A64; + --color-accent-focus: #78909C; + + --color-accent-fg: #FFF; + --color-accent-hover-fg: #FFF; + --color-accent-focus-fg: #FFF; + + --color-accent2: #484848; + --color-accent2-fg: #FFF; + + --color-red: #EF5350; + --color-green: #4CAF50; + --color-blue: #0288D1; + + --def-padding-main: 1em; + --def-border-radius: 6px; + + --color-white: #FFF; + --color-black: #000; +} + +html { + background: + linear-gradient(135deg,#0000 18.75%,var(--color-bg2) 0 31.25%,#0000 0), + repeating-linear-gradient(45deg,var(--color-bg2) -6.25% 6.25%,var(--color-bg) 0 18.75%); + background-size: 64px 64px; + padding: 0; + font-family: Sans; +} + +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 * { margin: 0; padding: 0; vertical-align: middle; } +header img { height: 1em; } + +h1 { display: inline-block; margin-left: 1em; font-size: .8em; } + +#wrap { display: grid; grid-template-columns: 400px 1fr; margin: 1em 2rem; grid-gap: 2em; } +#wrap > * { vertical-align: top; } + +aside { background: var(--color-bg); border-radius: 1px solid var(--color-borders); box-shadow: 0 0 6px var(--color-borders); border-radius: 2px; } + +h2 { margin: 0; padding: .8rem; font-size: 1.4em; + border-bottom: .15em solid var(--color-borders); } + +aside .label-input { display: grid; + grid-template-columns: 150px 2fr; 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); } + +select { background: transparent; } + +/*** + * Slider + * Thanks: mrhyddenn + * The following CSS slider is licensed under the MIT license, 2025 mrhyddenn. + * https://uiverse.io/mrhyddenn/old-fish-66 + * */ +/* The switch - the box around the slider */ +.switch { font-size: 17px; position: relative; display: inline-block; width: 62px; height: 35px; } + +/* Hide default HTML checkbox */ +.switch input { opacity: 1; width: 0; height: 0; +} + +/* The slider */ +.slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0px; + background: var(--color-bg); transition: .4s; border-radius: 30px; border: 1px solid var(--color-borders); +} + +.slider:before { position: absolute; content: ""; + height: 1.9em; width: 1.9em; border-radius: 16px; left: 1.2px; top: 0; bottom: 0 + background-color: var(--color-bg); box-shadow: 0 2px 5px var(--color-accent-hover); transition: .4s; +} + +input:checked + .slider { background-color: var(--color-accent); border: 1px solid transparent; +} + +input:checked + .slider:before { transform: translateX(1.5em); background-color: var(--color-accent-focus); } diff --git a/src/webui/assets/md-uploader.js b/src/webui/assets/md-uploader.js new file mode 100644 index 0000000..05967b5 --- /dev/null +++ b/src/webui/assets/md-uploader.js @@ -0,0 +1,183 @@ +"use strict"; + +class App { + + lang; + tls; + config; + parserList; + wrap; + aside; + main; + + generateTextElement(type, value) { + const elem = document.createElement(type); + elem.textContent = value; + return elem; + } + + generateLabelInput(id, labelText, type, value) { + + const output = document.createElement("div"); + output.classList.add("label-input"); + + const label = document.createElement("label"); + label.textContent = labelText; + label.setAttribute("for", id); + output.appendChild(label); + + const input = document.createElement("input"); + input.id = id; + input.type = "text"; + input.value = value; + output.appendChild(input); + + return {'wrap': output, 'input': input}; + + } + + async generateSidebar() { + + console.log("hi"); + this.aside.appendChild(this.generateTextElement("h2", "Settings")); // TODO + + // Set up fields + const instanceLinkArea = this.generateLabelInput("instance-link", + "Instance link", "url", this.config.settings.instance); + this.aside.appendChild(instanceLinkArea.wrap); + + const usernameArea = this.generateLabelInput("username", + "Username", "text", this.config.settings.username); + this.aside.appendChild(usernameArea.wrap); + + const mailArea = this.generateLabelInput("mail", + "Mail", "email", this.config.settings.mail); + this.aside.appendChild(mailArea.wrap); + + const institutionIdArea = this.generateLabelInput("institutionId", + "Institution ID", "number", this.config.settings.institution_id); + this.aside.appendChild(institutionIdArea.wrap); + + const parserDiv = document.createElement("div"); + parserDiv.classList.add("label-input"); + + const parserLabel = document.createElement("label"); + parserLabel.setAttribute("for", "parserSelect"); + parserLabel.textContent = "Parser"; + parserDiv.appendChild(parserLabel); + const parserSelect = document.createElement("select"); + parserSelect.id = "parserSelect"; + + for (const entry of this.parserList) { + const opt = document.createElement("option"); + opt.value = entry.title; + opt.textContent = entry.title; + if (entry.title === this.config.settings.parser) { + opt.setAttribute("selected", "selected"); + } + parserSelect.appendChild(opt); + } + + parserDiv.appendChild(parserSelect); + this.aside.appendChild(parserDiv); + + const uploadFolderArea = this.generateLabelInput("upload-folder", + "Upload directory", "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?"; + visibleDivOuter.appendChild(visibleLabel); + + const visibleDiv = document.createElement("div"); + const visibleSwitch = document.createElement("div"); + visibleSwitch.classList.add("switch"); + + const visibleInput = document.createElement("input"); + visibleInput.type = "checkbox"; + if (this.config.settings.visible === true) { + visibleInput.checked = "checked"; + console.log(visibleInput); + } + visibleSwitch.appendChild(visibleInput); + + const visibleSlider = document.createElement("span"); + visibleSlider.classList.add("slider"); + visibleSlider.addEventListener('click', function() { + visibleInput.checked = !visibleInput.checked; + console.log(visibleInput); + }); + visibleSwitch.appendChild(visibleSlider); + + visibleDiv.appendChild(visibleSwitch); + visibleDivOuter.appendChild(visibleDiv); + this.aside.appendChild(visibleDivOuter); + + } + + generateMain() { + + } + + render() { + + if (this.config.setup_required === true) { + wrap.classList.add("one-column"); + this.generateSidebar(); + } + else { + this.generateSidebar(); + this.generateMain(); + } + + } + + setupFromApis() { + + + const app = this; + let done = 0; + + function wrapFinish() { + if (done === 2) { + app.render(); + } + } + + window.fetch('/get-settings', {method: 'GET', cache: 'no-cache', credentials: 'same-origin'}) + .then(function(response) { return response.json(); }) + .then(function(elements) { + app.config = elements; + done++; + wrapFinish(); + }); + window.fetch('/list-parsers', {method: 'GET', cache: 'no-cache', credentials: 'same-origin'}) + .then(function(response) { return response.json(); }) + .then(function(elements) { + app.parserList = elements; + done++; + wrapFinish(); + }); + } + + constructor() { + + this.wrap = document.getElementById("wrap"); + this.lang = navigator.language.substring(0, 2) + + this.aside = document.createElement("aside"); + this.main = document.createElement("main"); + + this.wrap.appendChild(this.aside); + this.wrap.appendChild(this.main); + + this.setupFromApis(); + + } + +} + +new App(); diff --git a/src/webui/headers.go b/src/webui/headers.go new file mode 100644 index 0000000..037b826 --- /dev/null +++ b/src/webui/headers.go @@ -0,0 +1,46 @@ +package webui + +import ( + "net/http" +) + +// Sets generally useful HTTP headers, e.g. limiting frame-options. +func setDefaultHeaders(w http.ResponseWriter) { + + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("X-Frame-Options", "SAMEORIGIN") + w.Header().Set("X-XSS-Protection", "1; mode=block") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST") + w.Header().Set("Strict-Transport-Security", "max-age=63072000") + +} + +// Sets the HTTP headers expected to be returned when serving HTML pages. +func setHeadersForHtml(w http.ResponseWriter) { + setDefaultHeaders(w) + w.Header().Set("Content-Security-Policy", "default-src 'self' https:; font-src 'self'; object-src 'none'; frame-src 'self'; frame-ancestors 'none'; base-uri 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'") +} + +// Sets the HTTP headers expected to be returned when serving JSON. +func setHeadersForJson(w http.ResponseWriter) { + setDefaultHeaders(w) + w.Header().Set("Content-Type", "application/json") +} + +// Sets the HTTP headers expected to be returned when serving CSS files. +func setHeadersForCss(w http.ResponseWriter) { + setDefaultHeaders(w) + w.Header().Set("Content-Type", "text/css") +} + +// Sets the HTTP headers expected to be returned when serving JS files. +func setHeadersForJs(w http.ResponseWriter) { + setDefaultHeaders(w) + w.Header().Set("Content-Type", "text/javascript") +} + +// Sets the HTTP headers expected to be returned when serving SVG files. +func setHeadersForSvg(w http.ResponseWriter) { + setDefaultHeaders(w) + w.Header().Set("Content-Type", "image/svg+xml") +} diff --git a/src/webui/serveApiGetSettings.go b/src/webui/serveApiGetSettings.go new file mode 100644 index 0000000..b1be2ac --- /dev/null +++ b/src/webui/serveApiGetSettings.go @@ -0,0 +1,27 @@ +package webui + +import ( + "encoding/json" + "fmt" + "net/http" + "gitea.armuli.eu/museum-digital/museum-digital-webdav-uploader/src/configloader" +) + +type getSettingsApiResponse struct { + SetupRequired bool `json:"setup_required"` + Settings configloader.MDWebDavUploaderConfig `json:"settings"` +} + +// Generates the API output for listing current settings. +func serveApiGetSettings(w http.ResponseWriter, r *http.Request) { + + setHeadersForJson(w) + output := getSettingsApiResponse{SetupRequired: setupRequired, Settings: config} + + outputJson, encodeErr := json.Marshal(output) + if encodeErr != nil { + panic("Failed to create JSON") + } + + fmt.Fprint(w, string(outputJson)) +} diff --git a/src/webui/serveApiListParsers.go b/src/webui/serveApiListParsers.go new file mode 100644 index 0000000..cbae9cf --- /dev/null +++ b/src/webui/serveApiListParsers.go @@ -0,0 +1,22 @@ +package webui + +import ( + "encoding/json" + "fmt" + "net/http" + "gitea.armuli.eu/museum-digital/museum-digital-webdav-uploader/src/configloader" +) + +// Provides the JSON API for listing all available parsers. +func serveApiListParsers(w http.ResponseWriter, r *http.Request) { + + setHeadersForJson(w) + + outputJson, encodeErr := json.Marshal(configloader.ListParsers()) + if encodeErr != nil { + panic("Failed to create JSON") + } + + fmt.Fprint(w, string(outputJson)) + +} diff --git a/src/webui/serveApiListUploadable.go b/src/webui/serveApiListUploadable.go new file mode 100644 index 0000000..7797945 --- /dev/null +++ b/src/webui/serveApiListUploadable.go @@ -0,0 +1,36 @@ +package webui + +import ( + "encoding/json" + "fmt" + "net/http" + "gitea.armuli.eu/museum-digital/museum-digital-webdav-uploader/src/uploadsrcdir" +) + +type listUploadableApiResponse struct { + Metadata []string `json:"metadata"` + MediaFiles []string `json:"media_files"` +} + +// Provides the JSON API for listing all metadata and media files that +// are currently listed to be uploaded. +func serveApiListUploadable(w http.ResponseWriter, r *http.Request) { + + setHeadersForJson(w) + + if setupRequired == true { + fmt.Fprint(w, "{'metadata': '', 'media_files': []}") + return + } + + uploadableMetadata, uploadableMedia := uploadsrcdir.GetUploadableFiles(config) + output := listUploadableApiResponse{Metadata: uploadableMetadata, MediaFiles: uploadableMedia} + + outputJson, encodeErr := json.Marshal(output) + if encodeErr != nil { + panic("Failed to create JSON") + } + + fmt.Fprint(w, string(outputJson)) + +} diff --git a/src/webui/serveApiUpdateSettings.go b/src/webui/serveApiUpdateSettings.go new file mode 100644 index 0000000..bee2c1b --- /dev/null +++ b/src/webui/serveApiUpdateSettings.go @@ -0,0 +1,59 @@ +package webui + +import ( + "encoding/json" + "fmt" + "net/http" + "gitea.armuli.eu/museum-digital/museum-digital-webdav-uploader/src/configloader" +) + +type updateSettingsResponse struct { + Success bool `json:"success"` + ValidationError string `json:"error"` +} + +// Provides the API for updating the general configuration. +func serveApiUpdateSettings(w http.ResponseWriter, r *http.Request) { + + setHeadersForJson(w) + + // Parse POST data + parseErr := r.ParseForm() + if parseErr != nil { + fmt.Println("Failed to load form data") + return + } + + // Load new config from a POST variable 'settings' that provides the full + // settings as a JSON-encoded string. + rawInputConfig := r.PostForm.Get("settings") + var newConfig configloader.MDWebDavUploaderConfig + unmarshalErr := json.Unmarshal([]byte(rawInputConfig), &newConfig) + if unmarshalErr != nil { + fmt.Fprint(w, "{\"success\": false, \"error\": \"Invalid input JSON\"}") + return + } + + // Attempt to store the new settings. Validation takes place while storing + var output updateSettingsResponse + validationErr := configloader.StoreConfigToFile(newConfig, "") + if validationErr == nil { + output = updateSettingsResponse{Success: true, ValidationError: ""} + + // Reload config + config, setupRequired, _ = configloader.LoadFromFile("") + + } else { + output = updateSettingsResponse{Success: false, ValidationError: validationErr.Error()} + } + + // Format response in JSON, then print it + outputJson, encodeErr := json.Marshal(output) + if encodeErr != nil { + panic("Failed to create JSON") + } + + fmt.Fprint(w, string(outputJson)) + + +} diff --git a/src/webui/serveAppShell.go b/src/webui/serveAppShell.go new file mode 100644 index 0000000..6373e34 --- /dev/null +++ b/src/webui/serveAppShell.go @@ -0,0 +1,16 @@ +package webui + +import ( + _ "embed" + "fmt" + "net/http" +) + +//go:embed assets/index.htm +var indexHtml string + +// Serves the app shell. Contents are managed via APIs and JS. +func serveAppShell(w http.ResponseWriter, r *http.Request) { + setHeadersForHtml(w) + fmt.Fprint(w, indexHtml) +} diff --git a/src/webui/serveCss.go b/src/webui/serveCss.go new file mode 100644 index 0000000..4e5f70a --- /dev/null +++ b/src/webui/serveCss.go @@ -0,0 +1,15 @@ +package webui + +import ( + _ "embed" + "fmt" + "net/http" +) + +//go:embed assets/md-uploader.css +var webuiCss string + +func serveCss(w http.ResponseWriter, r *http.Request) { + setHeadersForCss(w) + fmt.Fprint(w, webuiCss) +} diff --git a/src/webui/serveJs.go b/src/webui/serveJs.go new file mode 100644 index 0000000..7e77a65 --- /dev/null +++ b/src/webui/serveJs.go @@ -0,0 +1,16 @@ +package webui + +import ( + _ "embed" + "fmt" + "net/http" +) + +//go:embed assets/md-uploader.js +var webuiJs string + +// Serves the JS for the web app. +func serveJs(w http.ResponseWriter, r *http.Request) { + setHeadersForJs(w) + fmt.Fprint(w, webuiJs) +} diff --git a/src/webui/serveLogo.go b/src/webui/serveLogo.go new file mode 100644 index 0000000..ff27be2 --- /dev/null +++ b/src/webui/serveLogo.go @@ -0,0 +1,16 @@ +package webui + +import ( + _ "embed" + "fmt" + "net/http" +) + +//go:embed assets/logo-md-code-black.svg +var logoSvg string + +// Serves the logo. +func serveLogo(w http.ResponseWriter, r *http.Request) { + setHeadersForSvg(w) + fmt.Fprint(w, logoSvg) +} diff --git a/src/webui/server.go b/src/webui/server.go new file mode 100644 index 0000000..69173b3 --- /dev/null +++ b/src/webui/server.go @@ -0,0 +1,39 @@ +package webui + +import ( + "fmt" + "log" + "net/http" + "gitea.armuli.eu/museum-digital/museum-digital-webdav-uploader/src/configloader" +) + +var config configloader.MDWebDavUploaderConfig +var setupRequired bool + +// Sets up the server and manages routing. +func Run() { + + config, setupRequired, _ = configloader.LoadFromFile("") + + // Bind callable URLs + http.HandleFunc("/", serveAppShell) // Serve app shell + http.HandleFunc("/get-settings", serveApiGetSettings) // 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 + // http.HandleFunc("/trigger-upload", serveFilePage) // Serve page for specific files + // http.HandleFunc("/contribute.json", ) // Serve page for specific files + http.HandleFunc("/md-uploader.css", serveCss) // Main CSS file. + http.HandleFunc("/md-uploader.js", serveJs) // Main JS file. + http.HandleFunc("/logo.svg", serveLogo) // Logo. + + port := "8080" + + // Set port to listen on + fmt.Println("Server started at port " + string(port)) + err := http.ListenAndServe(":"+port, nil) + if err != nil { + log.Fatal("ListenAndServe: ", err) + } + +}