This commit is contained in:
61
backend/private/index.html
Normal file
61
backend/private/index.html
Normal file
@@ -0,0 +1,61 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<title>API Server Configuration</title>
|
||||
<link href="style.css" rel="stylesheet" type="text/css" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="header">
|
||||
<div class="header-container">
|
||||
<div id="logo">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
style="isolation: isolate"
|
||||
viewBox="-680 -1030 300 180"
|
||||
>
|
||||
<g>
|
||||
<path
|
||||
d="M -430 -910 L -430 -910 C -424.481 -910 -420 -905.519 -420 -900 L -420 -900 C -420 -894.481 -424.481 -890 -430 -890 L -430 -890 C -435.519 -890 -440 -894.481 -440 -900 L -440 -900 C -440 -905.519 -435.519 -910 -430 -910 Z"
|
||||
></path>
|
||||
<path
|
||||
d=" M -570 -910 L -510 -910 C -504.481 -910 -500 -905.519 -500 -900 L -500 -900 C -500 -894.481 -504.481 -890 -510 -890 L -570 -890 C -575.519 -890 -580 -894.481 -580 -900 L -580 -900 C -580 -905.519 -575.519 -910 -570 -910 Z "
|
||||
></path>
|
||||
<path
|
||||
d="M -590 -970 L -590 -970 C -584.481 -970 -580 -965.519 -580 -960 L -580 -940 C -580 -934.481 -584.481 -930 -590 -930 L -590 -930 C -595.519 -930 -600 -934.481 -600 -940 L -600 -960 C -600 -965.519 -595.519 -970 -590 -970 Z"
|
||||
></path>
|
||||
<path
|
||||
d=" M -639.991 -960.515 C -639.72 -976.836 -626.385 -990 -610 -990 L -610 -990 C -602.32 -990 -595.31 -987.108 -590 -982.355 C -584.69 -987.108 -577.68 -990 -570 -990 L -570 -990 C -553.615 -990 -540.28 -976.836 -540.009 -960.515 C -540.001 -960.345 -540 -960.172 -540 -960 L -540 -960 L -540 -940 C -540 -934.481 -544.481 -930 -550 -930 L -550 -930 C -555.519 -930 -560 -934.481 -560 -940 L -560 -960 L -560 -960 C -560 -965.519 -564.481 -970 -570 -970 C -575.519 -970 -580 -965.519 -580 -960 L -580 -960 L -580 -960 L -580 -940 C -580 -934.481 -584.481 -930 -590 -930 L -590 -930 C -595.519 -930 -600 -934.481 -600 -940 L -600 -960 L -600 -960 L -600 -960 L -600 -960 L -600 -960 L -600 -960 L -600 -960 L -600 -960 C -600 -965.519 -604.481 -970 -610 -970 C -615.519 -970 -620 -965.519 -620 -960 L -620 -960 L -620 -940 C -620 -934.481 -624.481 -930 -630 -930 L -630 -930 C -635.519 -930 -640 -934.481 -640 -940 L -640 -960 L -640 -960 C -640 -960.172 -639.996 -960.344 -639.991 -960.515 Z "
|
||||
></path>
|
||||
<path
|
||||
d=" M -460 -930 L -460 -900 C -460 -894.481 -464.481 -890 -470 -890 L -470 -890 C -475.519 -890 -480 -894.481 -480 -900 L -480 -930 L -508.82 -930 C -514.99 -930 -520 -934.481 -520 -940 L -520 -940 C -520 -945.519 -514.99 -950 -508.82 -950 L -431.18 -950 C -425.01 -950 -420 -945.519 -420 -940 L -420 -940 C -420 -934.481 -425.01 -930 -431.18 -930 L -460 -930 Z "
|
||||
></path>
|
||||
<path
|
||||
d="M -470 -990 L -430 -990 C -424.481 -990 -420 -985.519 -420 -980 L -420 -980 C -420 -974.481 -424.481 -970 -430 -970 L -470 -970 C -475.519 -970 -480 -974.481 -480 -980 L -480 -980 C -480 -985.519 -475.519 -990 -470 -990 Z"
|
||||
></path>
|
||||
<path
|
||||
d=" M -630 -910 L -610 -910 C -604.481 -910 -600 -905.519 -600 -900 L -600 -900 C -600 -894.481 -604.481 -890 -610 -890 L -630 -890 C -635.519 -890 -640 -894.481 -640 -900 L -640 -900 C -640 -905.519 -635.519 -910 -630 -910 Z "
|
||||
></path>
|
||||
<path
|
||||
d=" M -515 -990 L -510 -990 C -504.481 -990 -500 -985.519 -500 -980 L -500 -980 C -500 -974.481 -504.481 -970 -510 -970 L -515 -970 C -520.519 -970 -525 -974.481 -525 -980 L -525 -980 C -525 -985.519 -520.519 -990 -515 -990 Z "
|
||||
></path>
|
||||
<path
|
||||
d=" M -660 -910 L -680 -910 L -680 -980 C -680 -1007.596 -657.596 -1030 -630 -1030 L -430 -1030 C -402.404 -1030 -380 -1007.596 -380 -980 L -380 -900 C -380 -872.404 -402.404 -850 -430 -850 L -630 -850 C -657.596 -850 -680 -872.404 -680 -900 L -680 -920 L -660 -920 L -660 -900 C -660 -883.443 -646.557 -870 -630 -870 L -430 -870 C -413.443 -870 -400 -883.443 -400 -900 L -400 -980 C -400 -996.557 -413.443 -1010 -430 -1010 L -630 -1010 C -646.557 -1010 -660 -996.557 -660 -980 L -660 -910 Z "
|
||||
></path>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<h1>API Server Configuration</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div id="root">
|
||||
<span id="form-loader" class="loader"></span>
|
||||
</div>
|
||||
<div id="save">Save Changes</div>
|
||||
<div id="export">Export Configuration</div>
|
||||
<script src="script.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
305
backend/private/script.js
Normal file
305
backend/private/script.js
Normal file
@@ -0,0 +1,305 @@
|
||||
let state = {};
|
||||
let schema = {};
|
||||
|
||||
const buildLabel = (elementType, text, hintText) => {
|
||||
const labelElement = document.createElement("label");
|
||||
labelElement.innerHTML = text;
|
||||
labelElement.style.fontWeight = elementType === "group" ? "bold" : "lighter";
|
||||
|
||||
if (hintText) {
|
||||
const hintElement = document.createElement("span");
|
||||
hintElement.classList.add("tooltip");
|
||||
hintElement.innerHTML = " ⓘ";
|
||||
|
||||
const hintTextElement = document.createElement("span");
|
||||
hintTextElement.classList.add("tooltip-text");
|
||||
hintTextElement.innerHTML = hintText;
|
||||
|
||||
hintElement.appendChild(hintTextElement);
|
||||
|
||||
labelElement.appendChild(hintElement);
|
||||
}
|
||||
|
||||
return labelElement;
|
||||
};
|
||||
|
||||
const buildNumberInput = (schema, parentState, key) => {
|
||||
const input = document.createElement("input");
|
||||
input.classList.add("base-input");
|
||||
input.type = "number";
|
||||
input.value = parentState[key];
|
||||
|
||||
const min = schema.min ?? 0;
|
||||
input.min = min;
|
||||
|
||||
input.addEventListener("change", () => {
|
||||
const normalizedValue = parseFloat(input.value, 10);
|
||||
parentState[key] = Math.max(normalizedValue, min);
|
||||
});
|
||||
|
||||
return input;
|
||||
};
|
||||
|
||||
const buildBooleanInput = (parentState, key) => {
|
||||
const input = document.createElement("input");
|
||||
input.classList.add("base-input");
|
||||
input.type = "checkbox";
|
||||
input.checked = parentState[key] ?? false;
|
||||
|
||||
input.addEventListener("change", () => {
|
||||
parentState[key] = input.checked;
|
||||
});
|
||||
|
||||
return input;
|
||||
};
|
||||
|
||||
const buildStringInput = (parentState, key) => {
|
||||
const input = document.createElement("input");
|
||||
input.classList.add("base-input");
|
||||
input.type = "text";
|
||||
input.value = parentState[key] ?? "";
|
||||
|
||||
input.addEventListener("change", () => {
|
||||
parentState[key] = input.value;
|
||||
});
|
||||
|
||||
return input;
|
||||
};
|
||||
|
||||
const defaultValueForType = (type) => {
|
||||
switch (type) {
|
||||
case "number":
|
||||
return 0;
|
||||
case "boolean":
|
||||
return false;
|
||||
case "string":
|
||||
return "";
|
||||
case "array":
|
||||
return [];
|
||||
case "object":
|
||||
return {};
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const arrayFormElementDecorator = (childElement, parentState, index) => {
|
||||
const decoratedElement = document.createElement("div");
|
||||
decoratedElement.classList.add("array-form-element-decorator");
|
||||
|
||||
const removeButton = document.createElement("button");
|
||||
removeButton.innerHTML = "X";
|
||||
removeButton.classList.add("array-input", "array-input-delete", "button");
|
||||
removeButton.addEventListener("click", () => {
|
||||
parentState.splice(index, 1);
|
||||
rerender();
|
||||
});
|
||||
|
||||
decoratedElement.appendChild(childElement);
|
||||
decoratedElement.appendChild(removeButton);
|
||||
|
||||
return decoratedElement;
|
||||
};
|
||||
|
||||
const buildArrayInput = (schema, parentState) => {
|
||||
const itemType = schema.items.type;
|
||||
const inputControlsDiv = document.createElement("div");
|
||||
inputControlsDiv.classList.add("array-input-controls");
|
||||
|
||||
const addButton = document.createElement("button");
|
||||
addButton.innerHTML = "Add One";
|
||||
addButton.classList.add("array-input", "button");
|
||||
addButton.addEventListener("click", () => {
|
||||
parentState.push(defaultValueForType(itemType));
|
||||
rerender();
|
||||
});
|
||||
|
||||
const removeButton = document.createElement("button");
|
||||
removeButton.innerHTML = "Delete All";
|
||||
removeButton.classList.add("array-input", "array-input-delete", "button");
|
||||
removeButton.addEventListener("click", () => {
|
||||
parentState.splice(0, parentState.length);
|
||||
rerender();
|
||||
});
|
||||
|
||||
inputControlsDiv.appendChild(addButton);
|
||||
inputControlsDiv.appendChild(removeButton);
|
||||
|
||||
return inputControlsDiv;
|
||||
};
|
||||
|
||||
const buildUnknownInput = () => {
|
||||
const disclaimer = document.createElement("div");
|
||||
disclaimer.innerHTML = `<i class="unknown-input">This configuration is not yet supported</i>`;
|
||||
|
||||
return disclaimer;
|
||||
};
|
||||
|
||||
const render = (state, schema) => {
|
||||
const build = (
|
||||
schema,
|
||||
state,
|
||||
parentState,
|
||||
currentKey = "",
|
||||
path = "configuration",
|
||||
) => {
|
||||
const parent = document.createElement("div");
|
||||
parent.classList.add("form-element");
|
||||
|
||||
const { type, label, hint, fields, items } = schema;
|
||||
|
||||
if (label) {
|
||||
parent.appendChild(buildLabel(type, label, hint));
|
||||
}
|
||||
|
||||
parent.id = path;
|
||||
|
||||
if (type === "object") {
|
||||
const entries = Object.entries(fields);
|
||||
entries.forEach(([key, value]) => {
|
||||
state[key] ??= defaultValueForType(value.type);
|
||||
|
||||
const childElement = build(
|
||||
value,
|
||||
state[key],
|
||||
state,
|
||||
key,
|
||||
`${path}.${key}`,
|
||||
);
|
||||
parent.appendChild(childElement);
|
||||
});
|
||||
} else if (type === "array") {
|
||||
const arrayInputControls = buildArrayInput(schema, state);
|
||||
parent.appendChild(arrayInputControls);
|
||||
|
||||
if (state && state.length > 0) {
|
||||
state.forEach((element, index) => {
|
||||
const childElement = build(
|
||||
items,
|
||||
element,
|
||||
state,
|
||||
index,
|
||||
`${path}[${index}]`,
|
||||
);
|
||||
|
||||
const decoratedChildElement = arrayFormElementDecorator(
|
||||
childElement,
|
||||
state,
|
||||
index,
|
||||
);
|
||||
parent.appendChild(decoratedChildElement);
|
||||
});
|
||||
}
|
||||
} else if (type === "number") {
|
||||
parent.appendChild(buildNumberInput(schema, parentState, currentKey));
|
||||
parent.classList.add("input-label");
|
||||
} else if (type === "string") {
|
||||
parent.appendChild(buildStringInput(parentState, currentKey));
|
||||
parent.classList.add("input-label");
|
||||
} else if (type === "boolean") {
|
||||
parent.appendChild(buildBooleanInput(parentState, currentKey));
|
||||
parent.classList.add("input-label");
|
||||
} else {
|
||||
parent.appendChild(buildUnknownInput());
|
||||
}
|
||||
|
||||
return parent;
|
||||
};
|
||||
|
||||
return build(schema, state, state);
|
||||
};
|
||||
|
||||
function rerender() {
|
||||
const root = document.querySelector("#root");
|
||||
root.innerHTML = "";
|
||||
root?.append(render(state, schema));
|
||||
}
|
||||
|
||||
window.onload = async () => {
|
||||
const [schemaResponse, dataResponse] = await Promise.all([
|
||||
fetch("/configuration/schema"),
|
||||
fetch("/configuration"),
|
||||
]);
|
||||
|
||||
const [schemaResponseJson, dataResponseJson] = await Promise.all([
|
||||
schemaResponse.json(),
|
||||
dataResponse.json(),
|
||||
]);
|
||||
|
||||
if (schemaResponse.status !== 200 || dataResponse.status !== 200) {
|
||||
const root = document.querySelector("#root");
|
||||
let html = "";
|
||||
if (schemaResponse.status !== 200) {
|
||||
html += `<i class="unknown-input">Error fetching configuration schema: ${schemaResponseJson.message}</i>`;
|
||||
}
|
||||
if (dataResponse.status !== 200) {
|
||||
html += `<i class="unknown-input">Error fetching configuration data: ${dataResponseJson.message}</i>`;
|
||||
}
|
||||
root.innerHTML = html;
|
||||
return;
|
||||
}
|
||||
|
||||
const { data: formSchema } = schemaResponseJson;
|
||||
const { data: initialData } = dataResponseJson;
|
||||
|
||||
state = initialData;
|
||||
schema = formSchema;
|
||||
|
||||
rerender();
|
||||
|
||||
const saveButton = document.querySelector("#save");
|
||||
|
||||
saveButton?.addEventListener("click", async () => {
|
||||
if (saveButton.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
saveButton.innerHTML = "Saving...";
|
||||
saveButton.disabled = true;
|
||||
const response = await fetch("/configuration", {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
configuration: state,
|
||||
}),
|
||||
});
|
||||
if (response.status === 200) {
|
||||
saveButton.innerHTML = "Saved!";
|
||||
saveButton.classList.add("good");
|
||||
} else {
|
||||
saveButton.innerHTML = "Failed!";
|
||||
saveButton.classList.add("bad");
|
||||
}
|
||||
setTimeout(() => {
|
||||
saveButton.innerHTML = "Save Changes";
|
||||
saveButton.classList.remove("good");
|
||||
saveButton.classList.remove("bad");
|
||||
saveButton.disabled = false;
|
||||
}, 3000);
|
||||
});
|
||||
|
||||
const exportButton = document.querySelector("#export");
|
||||
|
||||
exportButton.addEventListener("click", async () => {
|
||||
download(
|
||||
"backend-configuration.json",
|
||||
JSON.stringify({ configuration: state }),
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
function download(filename, text) {
|
||||
let element = document.createElement("a");
|
||||
element.setAttribute(
|
||||
"href",
|
||||
"data:text/plain;charset=utf-8," + encodeURIComponent(text),
|
||||
);
|
||||
element.setAttribute("download", filename);
|
||||
|
||||
element.style.display = "none";
|
||||
document.body.appendChild(element);
|
||||
element.click();
|
||||
document.body.removeChild(element);
|
||||
}
|
||||
204
backend/private/style.css
Normal file
204
backend/private/style.css
Normal file
@@ -0,0 +1,204 @@
|
||||
@import url("https://fonts.googleapis.com/css2?family=Roboto+Mono&display=swap");
|
||||
|
||||
:root {
|
||||
--roundness: 0.5rem;
|
||||
--bg-color: #323437;
|
||||
--main-color: #e2b714;
|
||||
--caret-color: #e2b714;
|
||||
--sub-color: #646669;
|
||||
--sub-alt-color: #2c2e31;
|
||||
--text-color: #d1d0c5;
|
||||
--error-color: #ca4754;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: "Roboto Mono", monospace;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: var(--bg-color);
|
||||
}
|
||||
|
||||
#header {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.header-container {
|
||||
padding: 1rem 0;
|
||||
max-width: 60rem;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
#logo {
|
||||
align-items: center;
|
||||
background-color: transparent;
|
||||
display: grid;
|
||||
width: 3rem;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
#header h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
#logo path {
|
||||
fill: var(--main-color);
|
||||
}
|
||||
|
||||
#root {
|
||||
padding: 2rem;
|
||||
background-color: #fff;
|
||||
max-width: 60rem;
|
||||
margin: 0rem auto;
|
||||
border-radius: var(--roundness);
|
||||
}
|
||||
|
||||
.button {
|
||||
background-color: var(--bg-color);
|
||||
color: #fff;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.25rem;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
margin-right: 1rem;
|
||||
font-family: "Roboto Mono", monospace;
|
||||
}
|
||||
|
||||
.array-input {
|
||||
margin: 1rem auto;
|
||||
}
|
||||
|
||||
.array-input-delete {
|
||||
margin-left: 1rem;
|
||||
background-color: #d84b4b;
|
||||
}
|
||||
|
||||
#save,
|
||||
#export {
|
||||
position: fixed;
|
||||
right: 6rem;
|
||||
bottom: 3rem;
|
||||
background-color: var(--sub-alt-color);
|
||||
color: var(--text-color);
|
||||
font-style: bold;
|
||||
border-radius: 3px;
|
||||
padding: 1rem 2rem;
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
width: 125px;
|
||||
text-align: center;
|
||||
transition: 0.125s;
|
||||
}
|
||||
|
||||
#export {
|
||||
bottom: 9rem;
|
||||
}
|
||||
|
||||
#save:hover,
|
||||
#export:hover {
|
||||
background-color: var(--text-color);
|
||||
color: var(--bg-color);
|
||||
}
|
||||
|
||||
#save.good {
|
||||
background-color: var(--main-color);
|
||||
color: var(--bg-color);
|
||||
}
|
||||
|
||||
#save.bad {
|
||||
background-color: var(--error-color);
|
||||
color: var(--bg-color);
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.base-input {
|
||||
margin: 0.5rem;
|
||||
border: 1px solid #767676;
|
||||
border-radius: calc(var(--roundness) / 2);
|
||||
background-color: #fff;
|
||||
transition: all 0.2s ease-in-out;
|
||||
font-size: 1rem;
|
||||
padding: 0.25rem;
|
||||
font-family: "Roboto Mono", monospace;
|
||||
}
|
||||
|
||||
input[type="checkbox"] {
|
||||
display: inline-block;
|
||||
accent-color: var(--main-color);
|
||||
color: white;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
}
|
||||
|
||||
.form-element {
|
||||
padding-left: 3rem;
|
||||
padding-top: 0.25rem;
|
||||
padding-bottom: 0.25rem;
|
||||
border-left: #64666955 0.5px solid;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.array-form-element-decorator {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
#root > .form-element:first-child {
|
||||
border-left: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.shadow {
|
||||
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.input-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.unknown-input {
|
||||
color: #d84b4b;
|
||||
}
|
||||
|
||||
.loader {
|
||||
margin: auto;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 5px solid var(--bg-color);
|
||||
border-bottom-color: transparent;
|
||||
border-radius: 50%;
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
animation: rotation 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes rotation {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.tooltip:hover .tooltip-text {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.tooltip-text {
|
||||
display: none;
|
||||
color: var(--text-color);
|
||||
background-color: var(--sub-alt-color);
|
||||
position: absolute;
|
||||
z-index: 1000;
|
||||
padding: 10px;
|
||||
border-radius: var(--roundness);
|
||||
}
|
||||
Reference in New Issue
Block a user