feat: add web editor base

This commit is contained in:
Louis Heredero 2025-04-28 09:07:45 +02:00
parent 3c4cc4b331
commit 79b3a2196c
Signed by: HEL
GPG Key ID: 8D83DE470F8544E7
7 changed files with 361 additions and 0 deletions

9
editor/public/base.css Normal file
View File

@ -0,0 +1,9 @@
* {
/*padding: 0;*/
/*margin: 0;*/
box-sizing: border-box;
}
body {
font-family: Ubuntu;
}

View File

@ -0,0 +1,13 @@
#filename {
font-size: 80%;
font-style: italic;
}
#unsaved {
color: #9d4916;
font-size: 80%;
&:not(.show) {
display: none;
}
}

View File

@ -0,0 +1,48 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Edit</title>
<link rel="stylesheet" href="/base.css">
<link rel="stylesheet" href="index.css">
<script src="index.js"></script>
</head>
<body>
<header id="topbar">
<nav>
</nav>
</header>
<aside id="toolbar">
</aside>
<main id="main">
<h1>Edit <code id="filename"></code><span id="unsaved"> - Unsaved</span></h1>
<div class="fields">
<div class="field">
<label for="title">Title</label>
<input type="text" id="title" name="title">
</div>
</div>
<div class="audio">
<h3>Audio Tracks</h3>
<table id="audio-tracks">
<thead>
<tr></tr>
</thead>
<tbody></tbody>
</table>
</div>
<div class="table">
<h3>Subtitle Tracks</h3>
<table id="subtitle-tracks">
<thead>
<tr></tr>
</thead>
<tbody></tbody>
</table>
</div>
</main>
</body>
</html>

157
editor/public/edit/index.js Normal file
View File

@ -0,0 +1,157 @@
/** @type TracksTable */
let audioTable
/** @type TracksTable */
let subtitleTable
function flattenObj(obj) {
const res = {}
Object.entries(obj).forEach(([key, value]) => {
if (typeof value === "object") {
value = flattenObj(value)
Object.entries(value).forEach(([key2, value2]) => {
res[key + "/" + key2] = value2
})
} else {
res[key] = value
}
})
return res
}
class TracksTable {
OPTIONS = {
"language": ["fre", "eng"]
}
constructor(table) {
this.table = table
this.headers = this.table.querySelector("thead tr")
this.body = this.table.querySelector("tbody")
this.fields = []
this.tracks = []
}
showTracks(tracks) {
this.tracks = tracks.map(flattenObj)
this.clear()
if (tracks.length === 0) {
return
}
this.detectFields()
this.addHeaders()
this.tracks.forEach((track, i) => {
const tr = document.createElement("tr")
tr.dataset.i = i
this.fields.forEach(field => {
const td = tr.insertCell(-1)
const input = this.makeInput(field, track[field.key])
td.appendChild(input)
})
this.body.appendChild(tr)
})
}
clear() {
this.headers.innerHTML = ""
this.body.innerHTML = ""
this.fields = []
}
detectFields() {
Object.entries(this.tracks[0]).forEach(([key, value]) => {
let type = {
boolean: "bool",
number: "num"
}[typeof value] ?? "str"
if (key === "language") {
type = "sel"
}
const name = key.split("/").slice(-1)[0]
this.fields.push({name, type, key})
})
}
addHeaders() {
this.fields.forEach(field => {
const th = document.createElement("th")
th.innerText = field.name
this.headers.appendChild(th)
})
}
makeInput(field, value) {
let input = document.createElement("input")
switch (field.type) {
case "num":
input.type = "number"
input.value = value
break
case "str":
input.type = "text"
input.value = value
break
case "bool":
input.type = "checkbox"
input.checked = value
break
case "sel":
input = document.createElement("select")
const options = this.OPTIONS[field.name]
options.forEach(option => {
const opt = document.createElement("option")
opt.innerText = option
opt.value = option
input.appendChild(opt)
})
input.value = value
default:
break
}
return input
}
}
function fetchData(filename) {
fetch("/api/file", {
method: "POST",
body: JSON.stringify({
file: filename
}),
headers: {
"Content-Type": "application/json"
}
}).then(res => {
if (res.ok) {
return res.json()
}
return null
}).then(res => {
if (res !== null) {
displayData(res)
}
})
}
function displayData(data) {
document.getElementById("title").value = data.title
audioTable.showTracks(data.audio_tracks)
subtitleTable.showTracks(data.subtitle_tracks)
}
window.addEventListener("load", () => {
audioTable = new TracksTable(document.getElementById("audio-tracks"))
subtitleTable = new TracksTable(document.getElementById("subtitle-tracks"))
const params = new URLSearchParams(window.location.search)
const file = params.get("f")
document.getElementById("filename").innerText = file
fetchData(file)
})

13
editor/public/index.html Normal file
View File

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Metadata Editor</title>
<script src="index.js"></script>
</head>
<body>
<h1>Metadata Editor</h1>
<select name="file-sel" id="file-sel" oninput="selectFile(event)"></select>
</body>
</html>

31
editor/public/index.js Normal file
View File

@ -0,0 +1,31 @@
function addOptions(files) {
const select = document.getElementById("file-sel")
select.innerHTML = ""
const defaultOpt = document.createElement("option")
defaultOpt.innerText = "----- Select a file -----"
defaultOpt.value = ""
select.appendChild(defaultOpt)
files.forEach(file => {
const option = document.createElement("option")
option.innerText = file
option.value = file
select.appendChild(option)
})
}
function selectFile(event) {
const file = event.target.value
if (file !== "") {
const url = new URL("/edit/", window.location.origin)
url.searchParams.set("f", file)
window.location.href = url.href
}
}
window.addEventListener("load", () => {
fetch("/api/files").then(res => {
return res.json()
}).then(files => {
addOptions(files)
})
})

90
editor/server.py Normal file
View File

@ -0,0 +1,90 @@
from http import HTTPStatus
from http.server import SimpleHTTPRequestHandler
import json
import os
import socketserver
from typing import Optional
from urllib.parse import urlparse, parse_qs
PORT = 8000
class MyHandler(SimpleHTTPRequestHandler):
DATA_DIR = "metadata"
def __init__(self, *args, **kwargs):
super().__init__(
*args,
directory="public",
**kwargs
)
self.query: dict = {}
self.data: Optional[dict|list] = None
def read_body_data(self):
self.log_message("Reading body data")
try:
raw_data = self.rfile.read(int(self.headers["Content-Length"]))
self.data = json.loads(raw_data)
except:
self.send_error(HTTPStatus.NOT_ACCEPTABLE, "Malformed JSON body")
self.log_error("Malformed JSON body")
return False
return True
def do_GET(self):
self.query = parse_qs(urlparse(self.path).query)
if self.path.startswith("/api/"):
self.handle_api_get(self.path.removeprefix("/api/").removesuffix("/"))
return
super().do_GET()
def do_POST(self):
self.query = parse_qs(urlparse(self.path).query)
if self.path.startswith("/api/"):
self.handle_api_post(self.path.removeprefix("/api/").removesuffix("/"))
return
self.send_error(HTTPStatus.NOT_FOUND)
def handle_api_get(self, path: str):
print(f"API request at {path}")
if path == "files":
files: list[str] = self.get_files()
self.send_json(files)
return
def handle_api_post(self, path: str):
if path == "file":
if self.read_body_data():
data = self.get_file(self.data["file"])
if data is None:
self.send_error(HTTPStatus.NOT_FOUND)
self.log_message("File not found")
else:
self.log_message("Got file")
self.send_json(data)
def send_json(self, data: dict|list):
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps(data).encode("utf-8"))
def get_files(self):
return os.listdir(self.DATA_DIR)
def get_file(self, filename: str) -> Optional[dict|list]:
if filename not in self.get_files():
return None
with open(os.path.join(self.DATA_DIR, filename), "r") as f:
data = json.load(f)
return data
def main():
with socketserver.TCPServer(("", PORT), MyHandler) as httpd:
print(f"Serving on port {PORT}")
httpd.serve_forever()
if __name__ == "__main__":
main()