Finish first version of WebUI

Close #10
This commit is contained in:
Joshua Ramon Enslin 2025-03-04 12:05:31 +01:00
parent dcc46f679b
commit b7923c9091
Signed by: jrenslin
GPG Key ID: 46016F84501B70AE
9 changed files with 216 additions and 40 deletions

3
.gitmodules vendored Normal file
View File

@ -0,0 +1,3 @@
[submodule "src/webui/translation-json"]
path = src/webui/translation-json
url = https://gitea.armuli.eu/museum-digital/translation-json.git

View File

@ -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.

View File

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

View File

@ -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

View File

@ -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");

View File

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

View File

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

View File

@ -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

@ -0,0 +1 @@
Subproject commit 6803016b709dcc0bf92ddfc29c6bf7bd9bd29cf7