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 @@
+
+
+
+
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)
+ }
+
+}