feat: Web metadata editor #2
1
editor/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
metadata/
|
@ -1,9 +0,0 @@
|
|||||||
* {
|
|
||||||
/*padding: 0;*/
|
|
||||||
/*margin: 0;*/
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: Ubuntu;
|
|
||||||
}
|
|
@ -1,13 +0,0 @@
|
|||||||
#filename {
|
|
||||||
font-size: 80%;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
#unsaved {
|
|
||||||
color: #9d4916;
|
|
||||||
font-size: 80%;
|
|
||||||
|
|
||||||
&:not(.show) {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
@ -4,25 +4,35 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Edit</title>
|
<title>Edit</title>
|
||||||
<link rel="stylesheet" href="/base.css">
|
<link rel="stylesheet" href="/static/css/base.css">
|
||||||
<link rel="stylesheet" href="index.css">
|
<link rel="stylesheet" href="/static/css/edit.css">
|
||||||
<script src="index.js"></script>
|
<script src="/static/js/edit.mjs" type="module"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header id="topbar">
|
<button id="improve-btn" class="template improve">
|
||||||
<nav>
|
<img src="/static/images/improve.svg">
|
||||||
|
<img class="clicked" src="/static/images/improve_clicked.svg">
|
||||||
</nav>
|
</button>
|
||||||
|
<header id="toolbar">
|
||||||
|
<a href="/">Home</a>
|
||||||
|
<button id="check-integrity">Check integrity</button>
|
||||||
|
<button id="improve-all">Improve all names</button>
|
||||||
|
<button id="save">Save</button>
|
||||||
|
<button id="reload">Reload</button>
|
||||||
|
<button id="toggle-notifs">Notifications</button>
|
||||||
</header>
|
</header>
|
||||||
<aside id="toolbar">
|
|
||||||
|
|
||||||
</aside>
|
|
||||||
<main id="main">
|
<main id="main">
|
||||||
<h1>Edit <code id="filename"></code><span id="unsaved"> - Unsaved</span></h1>
|
<h1>Editing <code id="filename"></code><span id="unsaved"> - Unsaved</span></h1>
|
||||||
|
<div id="series-toolbar">
|
||||||
|
<button id="prev-episode"></button>
|
||||||
|
<div id="cur-episode"></div>
|
||||||
|
<button id="next-episode"></button>
|
||||||
|
</div>
|
||||||
|
<div class="sep"></div>
|
||||||
<div class="fields">
|
<div class="fields">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="title">Title</label>
|
<label for="title">Title</label>
|
||||||
<input type="text" id="title" name="title">
|
<input type="text" id="title" name="title" size="50">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="audio">
|
<div class="audio">
|
||||||
@ -44,5 +54,63 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
<div class="popup" id="integrity-popup">
|
||||||
|
<form class="container">
|
||||||
|
<h2 class="title">Integrity Error</h2>
|
||||||
|
<p class="description">
|
||||||
|
There seem to be a mismatch between the name of <span id="int-track-type"></span> track <span id="int-track-idx"></span> and its '<span id="int-field-name"></span>' field
|
||||||
|
</p>
|
||||||
|
<div class="original">
|
||||||
|
<h3>Current values</h3>
|
||||||
|
<div class="values">
|
||||||
|
<div class="name">
|
||||||
|
<label for="int-og-name">name</label>
|
||||||
|
<input type="text" readonly id="int-og-name">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="int-og-field" id="int-og-field-name"></label>
|
||||||
|
<input type="text" readonly id="int-og-field">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="correction">
|
||||||
|
<h3>Correction</h3>
|
||||||
|
<div class="options">
|
||||||
|
<div class="option">
|
||||||
|
<input type="radio" value="nothing" name="int-corr-type" id="int-corr-nothing" checked>
|
||||||
|
<label for="int-corr-nothing">Do nothing</label>
|
||||||
|
</div>
|
||||||
|
<div class="option">
|
||||||
|
<input type="radio" value="name" name="int-corr-type" id="int-corr-name">
|
||||||
|
<label for="int-corr-name">Adapt name to field</label>
|
||||||
|
<div class="value">
|
||||||
|
<label for="int-corr-new-name">name</label>
|
||||||
|
<div class="arrow"></div>
|
||||||
|
<input type="text" readonly id="int-corr-new-name">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="option">
|
||||||
|
<input type="radio" value="field" name="int-corr-type" id="int-corr-field">
|
||||||
|
<label for="int-corr-field">Adapt field to name</label>
|
||||||
|
<div class="value">
|
||||||
|
<label for="int-corr-new-field" id="int-corr-new-field-name"></label>
|
||||||
|
<div class="arrow"></div>
|
||||||
|
<input type="text" readonly id="int-corr-new-field">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="buttons">
|
||||||
|
<button type="button" id="int-apply">Apply</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<aside id="notifs-hist">
|
||||||
|
<button id="close-notifs">Close</button>
|
||||||
|
<h2>Notifications</h2>
|
||||||
|
<div class="list"></div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div id="notifs"></div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
@ -1,157 +0,0 @@
|
|||||||
/** @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)
|
|
||||||
})
|
|
@ -4,10 +4,50 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Metadata Editor</title>
|
<title>Metadata Editor</title>
|
||||||
<script src="index.js"></script>
|
<link rel="stylesheet" href="/static/css/base.css">
|
||||||
|
<link rel="stylesheet" href="/static/css/index.css">
|
||||||
|
<script src="/static/js/index.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<header>
|
||||||
<h1>Metadata Editor</h1>
|
<h1>Metadata Editor</h1>
|
||||||
<select name="file-sel" id="file-sel" oninput="selectFile(event)"></select>
|
</header>
|
||||||
|
<main>
|
||||||
|
<a id="film-template" class="template file film">
|
||||||
|
<img src="/static/images/film.svg">
|
||||||
|
<div class="title"></div>
|
||||||
|
</a>
|
||||||
|
<a id="series-template" class="template file series">
|
||||||
|
<img src="/static/images/series.svg">
|
||||||
|
<div class="title"></div>
|
||||||
|
<div class="episodes"><span class="num"></span> episode(s)</div>
|
||||||
|
</a>
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="tool">
|
||||||
|
<label for="sort-by">Sort by</label>
|
||||||
|
<select id="sort-by">
|
||||||
|
<option value="title">Title</option>
|
||||||
|
<option value="ts">Last modified</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="tool">
|
||||||
|
<label for="sort-desc">Order</label>
|
||||||
|
<label class="toggle">
|
||||||
|
<input type="checkbox" id="sort-desc">
|
||||||
|
<div class="off">ASC</div>
|
||||||
|
<div class="on">DESC</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="tool">
|
||||||
|
<label for="filter">Filter</label>
|
||||||
|
<select id="filter">
|
||||||
|
<option value="all">All</option>
|
||||||
|
<option value="film">Films</option>
|
||||||
|
<option value="series">Series</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="files"></div>
|
||||||
|
</main>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
@ -1,31 +0,0 @@
|
|||||||
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)
|
|
||||||
})
|
|
||||||
})
|
|
107
editor/public/static/css/base.css
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: Ubuntu;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
padding: 1.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
background-color: #2b2b2b;
|
||||||
|
padding: 1.2em;
|
||||||
|
grid-area: header;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.8em;
|
||||||
|
color: white;
|
||||||
|
|
||||||
|
a, button {
|
||||||
|
padding: 0.4em 0.8em;
|
||||||
|
border: none;
|
||||||
|
color: black;
|
||||||
|
background-color: #e4e4e4;
|
||||||
|
font-size: inherit;
|
||||||
|
font-family: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 0.2em;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: #dbdbdb;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
aside {
|
||||||
|
position: fixed;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: white;
|
||||||
|
border-left: solid black 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notif {
|
||||||
|
--bg: #f0f0f0;
|
||||||
|
--border: #727272;
|
||||||
|
--fg: black;
|
||||||
|
--col: #f0f0f0;
|
||||||
|
|
||||||
|
&[data-type="success"] {
|
||||||
|
--bg: #e0ffe0;
|
||||||
|
--border: #727f72;
|
||||||
|
--fg: black;
|
||||||
|
--col: #8dff8d;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-type="error"] {
|
||||||
|
--bg: #ffe0e0;
|
||||||
|
--border: #7f7272;
|
||||||
|
--fg: black;
|
||||||
|
--col: #ff8d8d;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-type="warning"] {
|
||||||
|
--bg: #ffefe0;
|
||||||
|
--border: #7f7f72;
|
||||||
|
--fg: black;
|
||||||
|
--col: #ffc36a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#notifs {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4em;
|
||||||
|
padding-top: 0.4em;
|
||||||
|
width: max-content;
|
||||||
|
|
||||||
|
.notif {
|
||||||
|
padding: 0.4em 0.8em;
|
||||||
|
border-radius: 0.6em;
|
||||||
|
background-color: var(--bg);
|
||||||
|
border: solid var(--border) 2px;
|
||||||
|
color: var(--fg);
|
||||||
|
max-width: 30em;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
415
editor/public/static/css/edit.css
Normal file
@ -0,0 +1,415 @@
|
|||||||
|
main {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
#toggle-notifs {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
#filename {
|
||||||
|
font-size: 80%;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
#unsaved {
|
||||||
|
color: #9d4916;
|
||||||
|
font-size: 80%;
|
||||||
|
|
||||||
|
&:not(.show) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
th, td {
|
||||||
|
padding: 0.4em 0.8em;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody {
|
||||||
|
tr {
|
||||||
|
&:nth-child(even) {
|
||||||
|
background-color: #ececec;
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
text-align: center;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"], select {
|
||||||
|
font-size: inherit;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="checkbox"] {
|
||||||
|
--size: 0.6em;
|
||||||
|
--pad: 0.2em;
|
||||||
|
width: calc((var(--size) * 2 + var(--pad)) * 2);
|
||||||
|
height: calc((var(--size) + var(--pad)) * 2);
|
||||||
|
border-radius: calc(var(--size) + var(--pad));
|
||||||
|
appearance: none;
|
||||||
|
background-color: #e1e1e1;
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: "";
|
||||||
|
width: calc(var(--size) * 2);
|
||||||
|
height: calc(var(--size) * 2);
|
||||||
|
border-radius: calc(var(--size));
|
||||||
|
background-color: #9b9b9b;
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: var(--pad);
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:checked {
|
||||||
|
background-color: #daf0d1;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
left: auto;
|
||||||
|
right: var(--pad);
|
||||||
|
background-color: #6ee74a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="radio"] {
|
||||||
|
--size: 0.5em;
|
||||||
|
--pad: 0.3em;
|
||||||
|
width: calc((var(--size) + var(--pad)) * 2);
|
||||||
|
height: calc((var(--size) + var(--pad)) * 2);
|
||||||
|
border-radius: calc(var(--size) + var(--pad));
|
||||||
|
appearance: none;
|
||||||
|
background-color: #e1e1e1;
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:checked {
|
||||||
|
background-color: #daf0d1;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: "";
|
||||||
|
width: calc(var(--size) * 2);
|
||||||
|
height: calc(var(--size) * 2);
|
||||||
|
border-radius: calc(var(--size));
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
background-color: #6ee74a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.improve {
|
||||||
|
width: 2em;
|
||||||
|
height: 2em;
|
||||||
|
border: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
margin-left: 0.4em;
|
||||||
|
border-radius: 0.4em;
|
||||||
|
background: none;
|
||||||
|
|
||||||
|
&:hover{
|
||||||
|
background-color: #8d8d8d42;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: inherit;
|
||||||
|
height: inherit;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clicked {
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.clicked {
|
||||||
|
.clicked {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/*background: url("/static/images/improve.svg");
|
||||||
|
background-size: contain;
|
||||||
|
width: 2em;
|
||||||
|
height: 2em;
|
||||||
|
border: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
margin-left: 0.4em;
|
||||||
|
border-radius: 0.4em;
|
||||||
|
&:hover{
|
||||||
|
background-color: #8d8d8d42;
|
||||||
|
}
|
||||||
|
&::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
background: url("/static/images/improve_clicked.svg");
|
||||||
|
background-size: contain;
|
||||||
|
width: inherit;
|
||||||
|
height: inherit;
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.clicked {
|
||||||
|
&::after {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}*/
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background-color: #5c5c5c5c;
|
||||||
|
|
||||||
|
&:not(.show) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
padding: 1.2em;
|
||||||
|
border-radius: 0.8em;
|
||||||
|
background-color: white;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.8em;
|
||||||
|
|
||||||
|
.title {
|
||||||
|
text-align: center;
|
||||||
|
margin: 0.4em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2, h3 {
|
||||||
|
margin: 0;
|
||||||
|
margin-bottom: 0.4em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttons {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.8em;
|
||||||
|
|
||||||
|
button {
|
||||||
|
padding: 0.8em 1.6em;
|
||||||
|
background-color: #dfdfdf;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 0.4em;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
font-weight: bold;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: #e7e7e7;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
background-color: #d7d7d7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#integrity-popup {
|
||||||
|
h3 {
|
||||||
|
margin-bottom: 0.4em;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
span {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"] {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.original {
|
||||||
|
.values {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.2em;
|
||||||
|
padding-left: 1.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name, .field {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.4em;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.correction {
|
||||||
|
.options {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4em;
|
||||||
|
|
||||||
|
.option {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
gap: 0.2em 0.4em;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.4em;
|
||||||
|
border: solid black 2px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 0.2em;
|
||||||
|
|
||||||
|
&.selected {
|
||||||
|
border-color: #6ee74a;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="radio"] {
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
grid-column: 2;
|
||||||
|
grid-row: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrow {
|
||||||
|
background: url("/static/images/arrow.svg");
|
||||||
|
width: 2em;
|
||||||
|
height: 2em;
|
||||||
|
background-size: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
grid-column: 2;
|
||||||
|
grid-row: 2;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.2em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.select {
|
||||||
|
border: solid black 1px;
|
||||||
|
padding: 0.2em 0.4em;
|
||||||
|
background-color: #fafafa;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#notifs-hist {
|
||||||
|
padding: 0.8em;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-width: 30em;
|
||||||
|
transform: translateX(0%);
|
||||||
|
transition: transform 0.5s cubic-bezier(0.22, 0.61, 0.36, 1);
|
||||||
|
|
||||||
|
&:not(.show) {
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
#close-notifs {
|
||||||
|
align-self: flex-end;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0.4em 0.8em;
|
||||||
|
border-radius: 0.2em;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: #ebebeb;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.2em;
|
||||||
|
overflow-y: auto;
|
||||||
|
margin-top: 0.6em;
|
||||||
|
|
||||||
|
.notif {
|
||||||
|
border-left: solid var(--col) 4px;
|
||||||
|
padding: 0.4em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sep {
|
||||||
|
border-bottom: solid black 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#series-toolbar {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.4em;
|
||||||
|
padding: 0.4em;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
&:not(.show) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
background: var(--img);
|
||||||
|
width: 2.4em;
|
||||||
|
height: 2.4em;
|
||||||
|
background-color: transparent;
|
||||||
|
border: none;
|
||||||
|
background-size: 80%;
|
||||||
|
background-position: center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 0.4em;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: #f1f1f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&#prev-episode {
|
||||||
|
--img: url("/static/images/prev.svg");
|
||||||
|
}
|
||||||
|
|
||||||
|
&#next-episode {
|
||||||
|
--img: url("/static/images/next.svg");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
96
editor/public/static/css/index.css
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
#files {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(15em, 1fr));
|
||||||
|
grid-auto-rows: 15em;
|
||||||
|
gap: 0.8em;
|
||||||
|
place-items: center;
|
||||||
|
padding: 0.8em 0;
|
||||||
|
|
||||||
|
.file {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
text-decoration: none;
|
||||||
|
color: black;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
padding: 0.4em;
|
||||||
|
border-radius: 1.2em;
|
||||||
|
|
||||||
|
&.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: #f8f8f8;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 10em;
|
||||||
|
height: 10em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.2em;
|
||||||
|
border-bottom: solid black 1px;
|
||||||
|
padding: 0.4em 0;
|
||||||
|
|
||||||
|
.tool {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.2em;
|
||||||
|
|
||||||
|
label[for] {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
input, select {
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle {
|
||||||
|
height: 2em;
|
||||||
|
border-radius: 1em;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
user-select: none;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
input {
|
||||||
|
display: none;
|
||||||
|
|
||||||
|
&:not(:checked) ~ .off, &:checked ~ .on {
|
||||||
|
background-color: #6ee74a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
div {
|
||||||
|
padding: 0 0.4em;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
|
||||||
|
&.off {
|
||||||
|
border-radius: 1em 0 0 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.on {
|
||||||
|
border-radius: 0 1em 1em 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
82
editor/public/static/images/arrow.svg
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
width="64"
|
||||||
|
height="64"
|
||||||
|
viewBox="0 0 16.933333 16.933334"
|
||||||
|
version="1.1"
|
||||||
|
id="svg857"
|
||||||
|
inkscape:version="1.3.2 (1:1.3.2+202404261509+091e20ef0f)"
|
||||||
|
sodipodi:docname="after_icon.svg"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||||
|
xmlns:cc="http://creativecommons.org/ns#"
|
||||||
|
xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||||
|
<defs
|
||||||
|
id="defs851" />
|
||||||
|
<sodipodi:namedview
|
||||||
|
id="base"
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#666666"
|
||||||
|
borderopacity="1.0"
|
||||||
|
inkscape:pageopacity="0.0"
|
||||||
|
inkscape:pageshadow="2"
|
||||||
|
inkscape:zoom="6.9603479"
|
||||||
|
inkscape:cx="17.025011"
|
||||||
|
inkscape:cy="32.900654"
|
||||||
|
inkscape:document-units="mm"
|
||||||
|
inkscape:current-layer="layer1"
|
||||||
|
showgrid="true"
|
||||||
|
units="px"
|
||||||
|
inkscape:window-width="1366"
|
||||||
|
inkscape:window-height="716"
|
||||||
|
inkscape:window-x="0"
|
||||||
|
inkscape:window-y="24"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:showpageshadow="2"
|
||||||
|
inkscape:pagecheckerboard="0"
|
||||||
|
inkscape:deskcolor="#d1d1d1">
|
||||||
|
<inkscape:grid
|
||||||
|
type="xygrid"
|
||||||
|
id="grid1402"
|
||||||
|
empspacing="4"
|
||||||
|
originx="0"
|
||||||
|
originy="0"
|
||||||
|
spacingy="1.0583333"
|
||||||
|
spacingx="1.0583333"
|
||||||
|
units="px"
|
||||||
|
visible="true" />
|
||||||
|
</sodipodi:namedview>
|
||||||
|
<metadata
|
||||||
|
id="metadata854">
|
||||||
|
<rdf:RDF>
|
||||||
|
<cc:Work
|
||||||
|
rdf:about="">
|
||||||
|
<dc:format>image/svg+xml</dc:format>
|
||||||
|
<dc:type
|
||||||
|
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||||
|
</cc:Work>
|
||||||
|
</rdf:RDF>
|
||||||
|
</metadata>
|
||||||
|
<g
|
||||||
|
inkscape:label="Calque 1"
|
||||||
|
inkscape:groupmode="layer"
|
||||||
|
id="layer1"
|
||||||
|
transform="translate(0,-280.06665)">
|
||||||
|
<path
|
||||||
|
style="fill:none;fill-rule:evenodd;stroke:#000000;stroke-width:1.05833;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||||
|
d="M 2.1166666,288.53332 H 14.816666"
|
||||||
|
id="path1406"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
sodipodi:nodetypes="cc" />
|
||||||
|
<path
|
||||||
|
style="fill:none;stroke:#000000;stroke-width:1.05833;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
|
||||||
|
d="m 10.583333,285.35832 4.233333,3.175 -4.233333,3.175"
|
||||||
|
id="path1"
|
||||||
|
sodipodi:nodetypes="ccc" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 2.5 KiB |
148
editor/public/static/images/film.svg
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
width="128"
|
||||||
|
height="128"
|
||||||
|
viewBox="0 0 128 128"
|
||||||
|
version="1.1"
|
||||||
|
id="svg1"
|
||||||
|
inkscape:version="1.4 (e7c3feb100, 2024-10-09)"
|
||||||
|
sodipodi:docname="film.svg"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg">
|
||||||
|
<sodipodi:namedview
|
||||||
|
id="namedview1"
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#000000"
|
||||||
|
borderopacity="0.25"
|
||||||
|
inkscape:showpageshadow="2"
|
||||||
|
inkscape:pageopacity="0.0"
|
||||||
|
inkscape:pagecheckerboard="0"
|
||||||
|
inkscape:deskcolor="#d1d1d1"
|
||||||
|
inkscape:document-units="mm"
|
||||||
|
showgrid="true"
|
||||||
|
inkscape:zoom="5.7798541"
|
||||||
|
inkscape:cx="54.932183"
|
||||||
|
inkscape:cy="64.274979"
|
||||||
|
inkscape:window-width="1920"
|
||||||
|
inkscape:window-height="1016"
|
||||||
|
inkscape:window-x="0"
|
||||||
|
inkscape:window-y="27"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="layer1">
|
||||||
|
<inkscape:grid
|
||||||
|
id="grid1"
|
||||||
|
units="px"
|
||||||
|
originx="0"
|
||||||
|
originy="0"
|
||||||
|
spacingx="1"
|
||||||
|
spacingy="0.99999998"
|
||||||
|
empcolor="#0099e5"
|
||||||
|
empopacity="0.30196078"
|
||||||
|
color="#0099e5"
|
||||||
|
opacity="0.14901961"
|
||||||
|
empspacing="16"
|
||||||
|
enabled="true"
|
||||||
|
visible="true" />
|
||||||
|
</sodipodi:namedview>
|
||||||
|
<defs
|
||||||
|
id="defs1" />
|
||||||
|
<g
|
||||||
|
inkscape:label="Layer 1"
|
||||||
|
inkscape:groupmode="layer"
|
||||||
|
id="layer1">
|
||||||
|
<g
|
||||||
|
id="g12"
|
||||||
|
transform="translate(1.7078686e-5,-8)">
|
||||||
|
<g
|
||||||
|
id="g11">
|
||||||
|
<g
|
||||||
|
id="g10">
|
||||||
|
<circle
|
||||||
|
style="fill:none;stroke:#000000;stroke-width:2;stroke-linecap:square;stroke-dasharray:none;stroke-opacity:1"
|
||||||
|
id="path1"
|
||||||
|
cx="56"
|
||||||
|
cy="72"
|
||||||
|
r="40" />
|
||||||
|
<circle
|
||||||
|
style="fill:none;stroke:#000000;stroke-width:2;stroke-linecap:square;stroke-dasharray:none;stroke-opacity:1"
|
||||||
|
id="path1-5"
|
||||||
|
cx="56"
|
||||||
|
cy="72"
|
||||||
|
r="32" />
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
id="g7">
|
||||||
|
<g
|
||||||
|
id="g6">
|
||||||
|
<circle
|
||||||
|
style="fill:none;stroke:#000000;stroke-width:2;stroke-linecap:square;stroke-dasharray:none;stroke-opacity:1"
|
||||||
|
id="path2"
|
||||||
|
cx="56"
|
||||||
|
cy="53"
|
||||||
|
r="6" />
|
||||||
|
<circle
|
||||||
|
style="fill:none;stroke:#000000;stroke-width:2;stroke-linecap:square;stroke-dasharray:none;stroke-opacity:1"
|
||||||
|
id="path2-2"
|
||||||
|
cx="72.454483"
|
||||||
|
cy="62.5"
|
||||||
|
r="6" />
|
||||||
|
<circle
|
||||||
|
style="fill:none;stroke:#000000;stroke-width:2;stroke-linecap:square;stroke-dasharray:none;stroke-opacity:1"
|
||||||
|
id="path2-7"
|
||||||
|
cx="72.454483"
|
||||||
|
cy="81.5"
|
||||||
|
r="6" />
|
||||||
|
<circle
|
||||||
|
style="fill:none;stroke:#000000;stroke-width:2;stroke-linecap:square;stroke-dasharray:none;stroke-opacity:1"
|
||||||
|
id="path2-6"
|
||||||
|
cx="56"
|
||||||
|
cy="91"
|
||||||
|
r="6" />
|
||||||
|
<circle
|
||||||
|
style="fill:none;stroke:#000000;stroke-width:2;stroke-linecap:square;stroke-dasharray:none;stroke-opacity:1"
|
||||||
|
id="path2-61"
|
||||||
|
cx="39.545517"
|
||||||
|
cy="81.5"
|
||||||
|
r="6" />
|
||||||
|
<circle
|
||||||
|
style="fill:none;stroke:#000000;stroke-width:2;stroke-linecap:square;stroke-dasharray:none;stroke-opacity:1"
|
||||||
|
id="path2-20"
|
||||||
|
cx="39.545517"
|
||||||
|
cy="62.5"
|
||||||
|
r="6" />
|
||||||
|
</g>
|
||||||
|
<circle
|
||||||
|
style="fill:none;stroke:#000000;stroke-width:2;stroke-linecap:square;stroke-dasharray:none;stroke-opacity:1"
|
||||||
|
id="path3"
|
||||||
|
cx="56"
|
||||||
|
cy="72"
|
||||||
|
r="3" />
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
id="g9">
|
||||||
|
<path
|
||||||
|
style="fill:none;stroke:#000000;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
|
||||||
|
d="M 70.704221,109.19927 C 78.261106,107.5993 90,108 98,103 c 8,-5 12,-6 14,-7 l -7,-14 c -5,2 -6,7 -9,11 -3,4 -10.113506,5.585663 -10.113506,5.585663"
|
||||||
|
id="path4"
|
||||||
|
sodipodi:nodetypes="czcczc" />
|
||||||
|
<g
|
||||||
|
id="g8">
|
||||||
|
<path
|
||||||
|
style="fill:none;stroke:#000000;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
|
||||||
|
d="m 97.649449,90.439824 5.231401,9.711746"
|
||||||
|
id="path5" />
|
||||||
|
<path
|
||||||
|
style="fill:none;stroke:#000000;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
|
||||||
|
d="m 90.543116,96.977586 -0.626729,9.286354"
|
||||||
|
id="path6"
|
||||||
|
sodipodi:nodetypes="cc" />
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 4.9 KiB |
77
editor/public/static/images/improve.svg
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
width="64"
|
||||||
|
height="64"
|
||||||
|
viewBox="0 0 64 64"
|
||||||
|
version="1.1"
|
||||||
|
id="svg1"
|
||||||
|
inkscape:version="1.4.1 (1:1.4.1+202503302257+93de688d07)"
|
||||||
|
sodipodi:docname="improve.svg"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg">
|
||||||
|
<sodipodi:namedview
|
||||||
|
id="namedview1"
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#000000"
|
||||||
|
borderopacity="0.25"
|
||||||
|
inkscape:showpageshadow="2"
|
||||||
|
inkscape:pageopacity="0.0"
|
||||||
|
inkscape:pagecheckerboard="0"
|
||||||
|
inkscape:deskcolor="#d1d1d1"
|
||||||
|
inkscape:document-units="mm"
|
||||||
|
showgrid="true"
|
||||||
|
inkscape:zoom="8.8461329"
|
||||||
|
inkscape:cx="18.652218"
|
||||||
|
inkscape:cy="27.808761"
|
||||||
|
inkscape:window-width="2048"
|
||||||
|
inkscape:window-height="1228"
|
||||||
|
inkscape:window-x="0"
|
||||||
|
inkscape:window-y="24"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="layer1">
|
||||||
|
<inkscape:grid
|
||||||
|
id="grid1"
|
||||||
|
units="px"
|
||||||
|
originx="0"
|
||||||
|
originy="0"
|
||||||
|
spacingx="1"
|
||||||
|
spacingy="1"
|
||||||
|
empcolor="#0099e5"
|
||||||
|
empopacity="0.30196078"
|
||||||
|
color="#0099e5"
|
||||||
|
opacity="0.14901961"
|
||||||
|
empspacing="8"
|
||||||
|
enabled="true"
|
||||||
|
visible="true" />
|
||||||
|
</sodipodi:namedview>
|
||||||
|
<defs
|
||||||
|
id="defs1" />
|
||||||
|
<g
|
||||||
|
inkscape:label="Calque 1"
|
||||||
|
inkscape:groupmode="layer"
|
||||||
|
id="layer1">
|
||||||
|
<g
|
||||||
|
id="g1"
|
||||||
|
transform="matrix(2,0,0,2,-30,-24)">
|
||||||
|
<path
|
||||||
|
style="fill:none;stroke:#000000;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
|
||||||
|
d="m 24,32 c 3,0 8,-5 8,-8 0,3 5,8 8,8 -3,0 -8,5 -8,8 0,-3 -5,-8 -8,-8 z"
|
||||||
|
id="path1"
|
||||||
|
sodipodi:nodetypes="ccccc" />
|
||||||
|
<path
|
||||||
|
style="fill:none;stroke:#000000;stroke-width:0.7;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
|
||||||
|
d="m 19,22 c 1.875,0 5,-3.125 5,-5 0,1.875 3.125,5 5,5 -1.875,0 -5,3.125 -5,5 0,-1.875 -3.125,-5 -5,-5 z"
|
||||||
|
id="path1-3"
|
||||||
|
sodipodi:nodetypes="ccccc" />
|
||||||
|
<path
|
||||||
|
style="fill:none;stroke:#000000;stroke-width:0.5;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
|
||||||
|
d="m 35,19 c 1.125,0 3,-1.875 3,-3 0,1.125 1.875,3 3,3 -1.125,0 -3,1.875 -3,3 0,-1.125 -1.875,-3 -3,-3 z"
|
||||||
|
id="path1-1"
|
||||||
|
sodipodi:nodetypes="ccccc" />
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 2.5 KiB |
78
editor/public/static/images/improve_clicked.svg
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
width="64"
|
||||||
|
height="64"
|
||||||
|
viewBox="0 0 64 64"
|
||||||
|
version="1.1"
|
||||||
|
id="svg1"
|
||||||
|
inkscape:version="1.4.1 (1:1.4.1+202503302257+93de688d07)"
|
||||||
|
sodipodi:docname="improve_clicked.svg"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg">
|
||||||
|
<sodipodi:namedview
|
||||||
|
id="namedview1"
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#000000"
|
||||||
|
borderopacity="0.25"
|
||||||
|
inkscape:showpageshadow="2"
|
||||||
|
inkscape:pageopacity="0.0"
|
||||||
|
inkscape:pagecheckerboard="0"
|
||||||
|
inkscape:deskcolor="#d1d1d1"
|
||||||
|
inkscape:document-units="mm"
|
||||||
|
showgrid="true"
|
||||||
|
inkscape:zoom="8.8461329"
|
||||||
|
inkscape:cx="18.652218"
|
||||||
|
inkscape:cy="27.808761"
|
||||||
|
inkscape:window-width="2048"
|
||||||
|
inkscape:window-height="1228"
|
||||||
|
inkscape:window-x="0"
|
||||||
|
inkscape:window-y="24"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="layer1">
|
||||||
|
<inkscape:grid
|
||||||
|
id="grid1"
|
||||||
|
units="px"
|
||||||
|
originx="0"
|
||||||
|
originy="0"
|
||||||
|
spacingx="1"
|
||||||
|
spacingy="1"
|
||||||
|
empcolor="#0099e5"
|
||||||
|
empopacity="0.30196078"
|
||||||
|
color="#0099e5"
|
||||||
|
opacity="0.14901961"
|
||||||
|
empspacing="8"
|
||||||
|
enabled="true"
|
||||||
|
visible="true" />
|
||||||
|
</sodipodi:namedview>
|
||||||
|
<defs
|
||||||
|
id="defs1" />
|
||||||
|
<g
|
||||||
|
inkscape:label="Calque 1"
|
||||||
|
inkscape:groupmode="layer"
|
||||||
|
id="layer1">
|
||||||
|
<g
|
||||||
|
id="g1"
|
||||||
|
transform="matrix(2,0,0,2,-30,-24)"
|
||||||
|
style="fill:#efe348;fill-opacity:1">
|
||||||
|
<path
|
||||||
|
style="fill:#efe348;stroke:#000000;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1;fill-opacity:1"
|
||||||
|
d="m 24,32 c 3,0 8,-5 8,-8 0,3 5,8 8,8 -3,0 -8,5 -8,8 0,-3 -5,-8 -8,-8 z"
|
||||||
|
id="path1"
|
||||||
|
sodipodi:nodetypes="ccccc" />
|
||||||
|
<path
|
||||||
|
style="fill:#efe348;stroke:#000000;stroke-width:0.7;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1;fill-opacity:1"
|
||||||
|
d="m 19,22 c 1.875,0 5,-3.125 5,-5 0,1.875 3.125,5 5,5 -1.875,0 -5,3.125 -5,5 0,-1.875 -3.125,-5 -5,-5 z"
|
||||||
|
id="path1-3"
|
||||||
|
sodipodi:nodetypes="ccccc" />
|
||||||
|
<path
|
||||||
|
style="fill:#efe348;stroke:#000000;stroke-width:0.5;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1;fill-opacity:1"
|
||||||
|
d="m 35,19 c 1.125,0 3,-1.875 3,-3 0,1.125 1.875,3 3,3 -1.125,0 -3,1.875 -3,3 0,-1.125 -1.875,-3 -3,-3 z"
|
||||||
|
id="path1-1"
|
||||||
|
sodipodi:nodetypes="ccccc" />
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 2.6 KiB |
64
editor/public/static/images/next.svg
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
width="64mm"
|
||||||
|
height="64mm"
|
||||||
|
viewBox="0 0 64 64"
|
||||||
|
version="1.1"
|
||||||
|
id="svg1"
|
||||||
|
inkscape:version="1.3 (0e150ed6c4, 2023-07-21)"
|
||||||
|
sodipodi:docname="next_icon.svg"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg">
|
||||||
|
<sodipodi:namedview
|
||||||
|
id="namedview1"
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#000000"
|
||||||
|
borderopacity="0.25"
|
||||||
|
inkscape:showpageshadow="2"
|
||||||
|
inkscape:pageopacity="0.0"
|
||||||
|
inkscape:pagecheckerboard="0"
|
||||||
|
inkscape:deskcolor="#d1d1d1"
|
||||||
|
inkscape:document-units="mm"
|
||||||
|
showgrid="true"
|
||||||
|
inkscape:zoom="2.0560856"
|
||||||
|
inkscape:cx="8.7544994"
|
||||||
|
inkscape:cy="178.49452"
|
||||||
|
inkscape:window-width="1858"
|
||||||
|
inkscape:window-height="1016"
|
||||||
|
inkscape:window-x="1982"
|
||||||
|
inkscape:window-y="27"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="layer1">
|
||||||
|
<inkscape:grid
|
||||||
|
id="grid1"
|
||||||
|
units="px"
|
||||||
|
originx="0"
|
||||||
|
originy="0"
|
||||||
|
spacingx="0.26458333"
|
||||||
|
spacingy="0.26458333"
|
||||||
|
empcolor="#0099e5"
|
||||||
|
empopacity="0.30196078"
|
||||||
|
color="#0099e5"
|
||||||
|
opacity="0.14901961"
|
||||||
|
empspacing="8"
|
||||||
|
dotted="false"
|
||||||
|
gridanglex="30"
|
||||||
|
gridanglez="30"
|
||||||
|
visible="true" />
|
||||||
|
</sodipodi:namedview>
|
||||||
|
<defs
|
||||||
|
id="defs1" />
|
||||||
|
<g
|
||||||
|
inkscape:label="Layer 1"
|
||||||
|
inkscape:groupmode="layer"
|
||||||
|
id="layer1">
|
||||||
|
<path
|
||||||
|
style="color:#000000;fill:#000000;stroke-linecap:round;stroke-linejoin:round;-inkscape-stroke:none"
|
||||||
|
d="m 19.698341,10.884614 a 4.2333331,4.2333331 0 0 0 -2.763672,1.691406 4.2333331,4.2333331 0 0 0 0.933594,5.914062 L 36.444435,31.999848 17.868263,45.509614 a 4.2333331,4.2333331 0 0 0 -0.933594,5.914062 4.2333331,4.2333331 0 0 0 5.914063,0.933594 L 46.131935,35.423676 a 4.2337564,4.2337564 0 0 0 0,-6.847656 L 22.848732,11.642426 a 4.2333331,4.2333331 0 0 0 -3.150391,-0.757812 z"
|
||||||
|
id="path1" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 2.1 KiB |
64
editor/public/static/images/prev.svg
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
width="64mm"
|
||||||
|
height="64mm"
|
||||||
|
viewBox="0 0 64 64"
|
||||||
|
version="1.1"
|
||||||
|
id="svg1"
|
||||||
|
inkscape:version="1.3 (0e150ed6c4, 2023-07-21)"
|
||||||
|
sodipodi:docname="prev_icon.svg"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg">
|
||||||
|
<sodipodi:namedview
|
||||||
|
id="namedview1"
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#000000"
|
||||||
|
borderopacity="0.25"
|
||||||
|
inkscape:showpageshadow="2"
|
||||||
|
inkscape:pageopacity="0.0"
|
||||||
|
inkscape:pagecheckerboard="0"
|
||||||
|
inkscape:deskcolor="#d1d1d1"
|
||||||
|
inkscape:document-units="mm"
|
||||||
|
showgrid="true"
|
||||||
|
inkscape:zoom="2.0560856"
|
||||||
|
inkscape:cx="8.7544994"
|
||||||
|
inkscape:cy="178.49452"
|
||||||
|
inkscape:window-width="1858"
|
||||||
|
inkscape:window-height="1016"
|
||||||
|
inkscape:window-x="1982"
|
||||||
|
inkscape:window-y="27"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="layer1">
|
||||||
|
<inkscape:grid
|
||||||
|
id="grid1"
|
||||||
|
units="px"
|
||||||
|
originx="0"
|
||||||
|
originy="0"
|
||||||
|
spacingx="0.26458333"
|
||||||
|
spacingy="0.26458333"
|
||||||
|
empcolor="#0099e5"
|
||||||
|
empopacity="0.30196078"
|
||||||
|
color="#0099e5"
|
||||||
|
opacity="0.14901961"
|
||||||
|
empspacing="8"
|
||||||
|
dotted="false"
|
||||||
|
gridanglex="30"
|
||||||
|
gridanglez="30"
|
||||||
|
visible="true" />
|
||||||
|
</sodipodi:namedview>
|
||||||
|
<defs
|
||||||
|
id="defs1" />
|
||||||
|
<g
|
||||||
|
inkscape:label="Layer 1"
|
||||||
|
inkscape:groupmode="layer"
|
||||||
|
id="layer1">
|
||||||
|
<path
|
||||||
|
style="color:#000000;fill:#000000;stroke-linecap:round;stroke-linejoin:round;-inkscape-stroke:none"
|
||||||
|
d="m 44.301659,10.884614 a 4.2333331,4.2333331 0 0 1 2.763672,1.691406 4.2333331,4.2333331 0 0 1 -0.933594,5.914062 L 27.555565,31.999848 46.131737,45.509614 a 4.2333331,4.2333331 0 0 1 0.933594,5.914062 4.2333331,4.2333331 0 0 1 -5.914063,0.933594 L 17.868065,35.423676 a 4.2337564,4.2337564 0 0 1 0,-6.847656 L 41.151268,11.642426 a 4.2333331,4.2333331 0 0 1 3.150391,-0.757812 z"
|
||||||
|
id="path1" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 2.1 KiB |
188
editor/public/static/images/series.svg
Normal file
@ -0,0 +1,188 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
width="128"
|
||||||
|
height="128"
|
||||||
|
viewBox="0 0 128 128"
|
||||||
|
version="1.1"
|
||||||
|
id="svg1"
|
||||||
|
inkscape:version="1.4 (e7c3feb100, 2024-10-09)"
|
||||||
|
sodipodi:docname="series.svg"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg">
|
||||||
|
<sodipodi:namedview
|
||||||
|
id="namedview1"
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#000000"
|
||||||
|
borderopacity="0.25"
|
||||||
|
inkscape:showpageshadow="2"
|
||||||
|
inkscape:pageopacity="0.0"
|
||||||
|
inkscape:pagecheckerboard="0"
|
||||||
|
inkscape:deskcolor="#d1d1d1"
|
||||||
|
inkscape:document-units="mm"
|
||||||
|
showgrid="true"
|
||||||
|
inkscape:zoom="4.086974"
|
||||||
|
inkscape:cx="58.845493"
|
||||||
|
inkscape:cy="81.356034"
|
||||||
|
inkscape:window-width="1920"
|
||||||
|
inkscape:window-height="1016"
|
||||||
|
inkscape:window-x="0"
|
||||||
|
inkscape:window-y="27"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="layer1">
|
||||||
|
<inkscape:grid
|
||||||
|
id="grid1"
|
||||||
|
units="px"
|
||||||
|
originx="0"
|
||||||
|
originy="0"
|
||||||
|
spacingx="1"
|
||||||
|
spacingy="0.99999998"
|
||||||
|
empcolor="#0099e5"
|
||||||
|
empopacity="0.30196078"
|
||||||
|
color="#0099e5"
|
||||||
|
opacity="0.14901961"
|
||||||
|
empspacing="16"
|
||||||
|
enabled="true"
|
||||||
|
visible="true" />
|
||||||
|
</sodipodi:namedview>
|
||||||
|
<defs
|
||||||
|
id="defs1">
|
||||||
|
<inkscape:path-effect
|
||||||
|
effect="fillet_chamfer"
|
||||||
|
id="path-effect15"
|
||||||
|
is_visible="true"
|
||||||
|
lpeversion="1"
|
||||||
|
nodesatellites_param="F,0,1,1,0,8,0,1 @ F,0,1,1,0,8,0,1 @ F,0,1,1,0,8,0,1 @ F,0,0,1,0,8,0,1"
|
||||||
|
radius="0"
|
||||||
|
unit="px"
|
||||||
|
method="auto"
|
||||||
|
mode="F"
|
||||||
|
chamfer_steps="1"
|
||||||
|
flexible="false"
|
||||||
|
use_knot_distance="true"
|
||||||
|
apply_no_radius="true"
|
||||||
|
apply_with_radius="true"
|
||||||
|
only_selected="false"
|
||||||
|
hide_knots="false" />
|
||||||
|
<inkscape:path-effect
|
||||||
|
effect="fillet_chamfer"
|
||||||
|
id="path-effect14"
|
||||||
|
is_visible="true"
|
||||||
|
lpeversion="1"
|
||||||
|
nodesatellites_param="F,0,1,1,0,2.1961523,0,1 @ F,0,1,1,0,2.1961523,0,1 @ F,0,0,1,0,2.1961523,0,1"
|
||||||
|
radius="0"
|
||||||
|
unit="px"
|
||||||
|
method="auto"
|
||||||
|
mode="F"
|
||||||
|
chamfer_steps="1"
|
||||||
|
flexible="false"
|
||||||
|
use_knot_distance="true"
|
||||||
|
apply_no_radius="true"
|
||||||
|
apply_with_radius="true"
|
||||||
|
only_selected="false"
|
||||||
|
hide_knots="false" />
|
||||||
|
<inkscape:path-effect
|
||||||
|
effect="fillet_chamfer"
|
||||||
|
id="path-effect15-4"
|
||||||
|
is_visible="true"
|
||||||
|
lpeversion="1"
|
||||||
|
nodesatellites_param="F,0,1,1,0,8,0,1 @ F,0,1,1,0,8,0,1 @ F,0,1,1,0,8,0,1 @ F,0,0,1,0,8,0,1"
|
||||||
|
radius="0"
|
||||||
|
unit="px"
|
||||||
|
method="auto"
|
||||||
|
mode="F"
|
||||||
|
chamfer_steps="1"
|
||||||
|
flexible="false"
|
||||||
|
use_knot_distance="true"
|
||||||
|
apply_no_radius="true"
|
||||||
|
apply_with_radius="true"
|
||||||
|
only_selected="false"
|
||||||
|
hide_knots="false" />
|
||||||
|
<inkscape:path-effect
|
||||||
|
effect="fillet_chamfer"
|
||||||
|
id="path-effect15-8"
|
||||||
|
is_visible="true"
|
||||||
|
lpeversion="1"
|
||||||
|
nodesatellites_param="F,0,1,1,0,8,0,1 @ F,0,1,1,0,8,0,1 @ F,0,1,1,0,8,0,1 @ F,0,0,1,0,8,0,1"
|
||||||
|
radius="0"
|
||||||
|
unit="px"
|
||||||
|
method="auto"
|
||||||
|
mode="F"
|
||||||
|
chamfer_steps="1"
|
||||||
|
flexible="false"
|
||||||
|
use_knot_distance="true"
|
||||||
|
apply_no_radius="true"
|
||||||
|
apply_with_radius="true"
|
||||||
|
only_selected="false"
|
||||||
|
hide_knots="false" />
|
||||||
|
<inkscape:path-effect
|
||||||
|
effect="fillet_chamfer"
|
||||||
|
id="path-effect15-9"
|
||||||
|
is_visible="true"
|
||||||
|
lpeversion="1"
|
||||||
|
nodesatellites_param="F,0,1,1,0,8,0,1 @ F,0,1,1,0,8,0,1 @ F,0,1,1,0,8,0,1 @ F,0,0,1,0,8,0,1"
|
||||||
|
radius="0"
|
||||||
|
unit="px"
|
||||||
|
method="auto"
|
||||||
|
mode="F"
|
||||||
|
chamfer_steps="1"
|
||||||
|
flexible="false"
|
||||||
|
use_knot_distance="true"
|
||||||
|
apply_no_radius="true"
|
||||||
|
apply_with_radius="true"
|
||||||
|
only_selected="false"
|
||||||
|
hide_knots="false" />
|
||||||
|
</defs>
|
||||||
|
<g
|
||||||
|
inkscape:label="Layer 1"
|
||||||
|
inkscape:groupmode="layer"
|
||||||
|
id="layer1">
|
||||||
|
<g
|
||||||
|
id="g4"
|
||||||
|
transform="translate(-8,-8)">
|
||||||
|
<g
|
||||||
|
id="g3">
|
||||||
|
<path
|
||||||
|
style="fill:none;stroke:#000000;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
|
||||||
|
d="m 24,56 v 48 a 8,8 45 0 0 8,8 h 64 a 8,8 135 0 0 8,-8 V 56 A 8,8 45 0 0 96,48 H 32 a 8,8 135 0 0 -8,8 z"
|
||||||
|
id="path12"
|
||||||
|
inkscape:path-effect="#path-effect15"
|
||||||
|
inkscape:original-d="m 24,48 v 64 h 80 V 48 Z" />
|
||||||
|
<g
|
||||||
|
id="g1">
|
||||||
|
<circle
|
||||||
|
style="fill:none;stroke:#000000;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
|
||||||
|
id="path13"
|
||||||
|
cx="64"
|
||||||
|
cy="80"
|
||||||
|
r="16" />
|
||||||
|
<path
|
||||||
|
id="path14"
|
||||||
|
style="fill:none;stroke:#000000;stroke-width:2;stroke-linecap:round;stroke-linejoin:round"
|
||||||
|
inkscape:transform-center-x="-2"
|
||||||
|
transform="translate(-1)"
|
||||||
|
d="m 70.098076,81.098076 -8.196152,4.732051 A 1.2679491,1.2679491 30 0 1 60,84.732051 v -9.464102 a 1.2679491,1.2679491 150 0 1 1.901924,-1.098076 l 8.196152,4.732051 a 1.2679491,1.2679491 90 0 1 0,2.196152 z"
|
||||||
|
inkscape:path-effect="#path-effect14"
|
||||||
|
inkscape:original-d="M 72,80 60,86.928203 V 73.071797 Z" />
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
id="g2">
|
||||||
|
<path
|
||||||
|
style="fill:none;stroke:#000000;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
|
||||||
|
d="M 99.289904,111.29439 C 102.06725,110.03979 104,107.2456 104,104 V 56 c 0,-4.418278 -3.58172,-8 -8,-8 H 32 c -3.244751,0 -6.038321,1.93174 -7.293397,4.707908"
|
||||||
|
id="path12-9"
|
||||||
|
transform="translate(8,-8)"
|
||||||
|
sodipodi:nodetypes="cssssc" />
|
||||||
|
<path
|
||||||
|
style="fill:none;stroke:#000000;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
|
||||||
|
d="M 115.2899,95.29439 C 118.06725,94.03979 120,91.2456 120,88 V 40 c 0,-4.418278 -3.58172,-8 -8,-8 H 48 c -3.244751,0 -6.038321,1.93174 -7.293397,4.707908"
|
||||||
|
id="path12-9-5"
|
||||||
|
sodipodi:nodetypes="cssssc" />
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 6.2 KiB |
8
editor/public/static/js/edit.mjs
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import Editor from "./editor.mjs";
|
||||||
|
import * as utils from "./utils.mjs"
|
||||||
|
|
||||||
|
window.addEventListener("load", () => {
|
||||||
|
const editor = new Editor()
|
||||||
|
window.editor = editor
|
||||||
|
window.utils = utils
|
||||||
|
})
|
170
editor/public/static/js/editor.mjs
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
import TracksTable from "./tracks_table.mjs"
|
||||||
|
import IntegrityManager from "./integrity_manager.mjs"
|
||||||
|
import { updateObjectFromJoinedKey } from "./utils.mjs"
|
||||||
|
import { loadMetadata, SeriesMetadata } from "./metadata.mjs"
|
||||||
|
|
||||||
|
export default class Editor {
|
||||||
|
constructor() {
|
||||||
|
const params = new URLSearchParams(window.location.search)
|
||||||
|
this.filename = params.get("f")
|
||||||
|
window.addEventListener("keydown", e => {
|
||||||
|
if (e.key === "s" && e.ctrlKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
this.save()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
this.tables = {
|
||||||
|
audio: new TracksTable(this, "audio", "audio-tracks", "audio_tracks"),
|
||||||
|
subtitle: new TracksTable(this, "subtitle", "subtitle-tracks", "subtitle_tracks")
|
||||||
|
}
|
||||||
|
|
||||||
|
this.metadata = null
|
||||||
|
this.data = {}
|
||||||
|
this.dirty = false
|
||||||
|
|
||||||
|
this.integrityMgr = new IntegrityManager(this)
|
||||||
|
|
||||||
|
document.getElementById("check-integrity").addEventListener("click", () => this.checkIntegrity())
|
||||||
|
document.getElementById("improve-all").addEventListener("click", () => this.improveAllNames())
|
||||||
|
document.getElementById("save").addEventListener("click", () => this.save())
|
||||||
|
document.getElementById("reload").addEventListener("click", () => window.location.reload())
|
||||||
|
document.getElementById("toggle-notifs").addEventListener("click", () => this.toggleNotifications())
|
||||||
|
document.getElementById("close-notifs").addEventListener("click", () => this.closeNotifications())
|
||||||
|
document.getElementById("prev-episode").addEventListener("click", () => this.prevEpisode())
|
||||||
|
document.getElementById("next-episode").addEventListener("click", () => this.nextEpisode())
|
||||||
|
|
||||||
|
this.titleInput = document.getElementById("title")
|
||||||
|
this.titleInput.addEventListener("change", () => {
|
||||||
|
this.data.title = this.titleInput.value
|
||||||
|
this.setDirty()
|
||||||
|
})
|
||||||
|
|
||||||
|
this.setup()
|
||||||
|
}
|
||||||
|
|
||||||
|
setup() {
|
||||||
|
document.getElementById("filename").innerText = this.filename
|
||||||
|
this.fetchData()
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchData() {
|
||||||
|
fetch(`/api/file/${this.filename}`).then(res => {
|
||||||
|
if (res.ok) {
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}).then(res => {
|
||||||
|
if (res !== null) {
|
||||||
|
this.metadata = loadMetadata(res)
|
||||||
|
this.displayData()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
displayData() {
|
||||||
|
const seriesToolbar = document.getElementById("series-toolbar")
|
||||||
|
if (this.metadata instanceof SeriesMetadata) {
|
||||||
|
seriesToolbar.classList.add("show")
|
||||||
|
const cur = this.metadata.episodeIdx + 1
|
||||||
|
const tot = this.metadata.episodes.length
|
||||||
|
const epMeta = this.metadata.getCurrentEpisode()
|
||||||
|
const season = epMeta.season
|
||||||
|
const episode = epMeta.episode
|
||||||
|
seriesToolbar.querySelector("#cur-episode").innerText = `S${season}E${episode} (${cur} / ${tot})`
|
||||||
|
} else {
|
||||||
|
seriesToolbar.classList.remove("show")
|
||||||
|
}
|
||||||
|
this.data = this.metadata.getData()
|
||||||
|
this.titleInput.value = this.data.title
|
||||||
|
this.tables.audio.loadTracks(this.data.audio_tracks)
|
||||||
|
this.tables.subtitle.loadTracks(this.data.subtitle_tracks)
|
||||||
|
}
|
||||||
|
|
||||||
|
save() {
|
||||||
|
fetch(`/api/file/${this.filename}`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(this.metadata.data),
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
}).then(res => {
|
||||||
|
if (res.ok) {
|
||||||
|
this.dirty = false
|
||||||
|
document.getElementById("unsaved").classList.remove("show")
|
||||||
|
this.notify("Saved successfully !", "success")
|
||||||
|
} else {
|
||||||
|
this.notify(`Error ${res.status}: ${res.statusText}`, "error", 10000)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
setDirty() {
|
||||||
|
this.dirty = true
|
||||||
|
document.getElementById("unsaved").classList.add("show")
|
||||||
|
}
|
||||||
|
|
||||||
|
editTrack(listKey, trackIdx, key, value) {
|
||||||
|
updateObjectFromJoinedKey(this.data[listKey][trackIdx], key, value)
|
||||||
|
this.setDirty()
|
||||||
|
}
|
||||||
|
|
||||||
|
notify(text, type, duration=5000) {
|
||||||
|
const list = document.getElementById("notifs")
|
||||||
|
const hist = document.getElementById("notifs-hist").querySelector(".list")
|
||||||
|
const notif = document.createElement("div")
|
||||||
|
notif.classList.add("notif")
|
||||||
|
notif.dataset.type = type
|
||||||
|
notif.innerText = text
|
||||||
|
list.appendChild(notif)
|
||||||
|
setTimeout(() => notif.remove(), duration)
|
||||||
|
notif.addEventListener("click", () => notif.remove())
|
||||||
|
hist.prepend(notif.cloneNode(true))
|
||||||
|
}
|
||||||
|
|
||||||
|
checkIntegrity() {
|
||||||
|
if (this.integrityMgr.checkIntegrity()) {
|
||||||
|
this.notify("No integrity error detected !", "success")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
improveAllNames() {
|
||||||
|
this.integrityMgr.improveAllNames()
|
||||||
|
this.notify("Improved all names !", "success")
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleNotifications() {
|
||||||
|
const hist = document.getElementById("notifs-hist")
|
||||||
|
if (hist.classList.contains("show")) {
|
||||||
|
this.closeNotifications()
|
||||||
|
} else {
|
||||||
|
this.openNotifications()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
openNotifications() {
|
||||||
|
const hist = document.getElementById("notifs-hist")
|
||||||
|
hist.classList.add("show")
|
||||||
|
}
|
||||||
|
|
||||||
|
closeNotifications() {
|
||||||
|
const hist = document.getElementById("notifs-hist")
|
||||||
|
hist.classList.remove("show")
|
||||||
|
}
|
||||||
|
|
||||||
|
prevEpisode() {
|
||||||
|
if (this.metadata instanceof SeriesMetadata) {
|
||||||
|
if (this.metadata.prev()) {
|
||||||
|
this.displayData()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nextEpisode() {
|
||||||
|
if (this.metadata instanceof SeriesMetadata) {
|
||||||
|
if (this.metadata.next()) {
|
||||||
|
this.displayData()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
107
editor/public/static/js/index.js
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
let fileNodes = []
|
||||||
|
|
||||||
|
function makeFilm(meta) {
|
||||||
|
const file = document.getElementById("film-template").cloneNode(true)
|
||||||
|
file.querySelector(".title").innerText = meta.title
|
||||||
|
return file
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeSeries(meta) {
|
||||||
|
const file = document.getElementById("series-template").cloneNode(true)
|
||||||
|
file.querySelector(".title").innerText = meta.title
|
||||||
|
file.querySelector(".episodes .num").innerText = meta.episodes
|
||||||
|
return file
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeFile(meta) {
|
||||||
|
let file
|
||||||
|
switch (meta.type) {
|
||||||
|
case "film":
|
||||||
|
file = makeFilm(meta)
|
||||||
|
break
|
||||||
|
case "series":
|
||||||
|
file = makeSeries(meta)
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
throw new Error(`Invalid file type '${meta.type}'`)
|
||||||
|
}
|
||||||
|
|
||||||
|
file.title = meta.filename
|
||||||
|
file.id = null
|
||||||
|
file.classList.remove("template")
|
||||||
|
const url = new URL("/edit/", window.location.origin)
|
||||||
|
url.searchParams.set("f", meta.filename)
|
||||||
|
file.href = url.href
|
||||||
|
return file
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {object[]} files
|
||||||
|
*/
|
||||||
|
function addFiles(files) {
|
||||||
|
const list = document.getElementById("files")
|
||||||
|
list.innerHTML = ""
|
||||||
|
const filenames = files.map(meta => meta.filename)
|
||||||
|
// Copy array because sort changes it in place
|
||||||
|
Array.from(filenames).sort().forEach(filename => {
|
||||||
|
const i = filenames.indexOf(filename)
|
||||||
|
const meta = files[i]
|
||||||
|
const file = makeFile(meta)
|
||||||
|
list.appendChild(file)
|
||||||
|
fileNodes.push([meta, file])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortFiles() {
|
||||||
|
const sortBy = document.getElementById("sort-by").value
|
||||||
|
const sortDesc = document.getElementById("sort-desc").checked
|
||||||
|
const filter = document.getElementById("filter").value
|
||||||
|
|
||||||
|
fileNodes.forEach(([meta, node]) => {
|
||||||
|
if (node.classList.contains(filter) || filter === "all") {
|
||||||
|
node.classList.remove("hidden")
|
||||||
|
} else {
|
||||||
|
node.classList.add("hidden")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
let changed = false
|
||||||
|
do {
|
||||||
|
changed = false
|
||||||
|
for (let i = 0; i < fileNodes.length - 1; i++) {
|
||||||
|
/** @type {[object, HTMLElement]} */
|
||||||
|
const pair1 = fileNodes[i]
|
||||||
|
/** @type {[object, HTMLElement]} */
|
||||||
|
const pair2 = fileNodes[i + 1]
|
||||||
|
const [meta1, node1] = pair1
|
||||||
|
const [meta2, node2] = pair2
|
||||||
|
|
||||||
|
let swap = false
|
||||||
|
if (sortDesc) {
|
||||||
|
swap = !(meta1[sortBy] >= meta2[sortBy])
|
||||||
|
} else {
|
||||||
|
swap = !(meta2[sortBy] >= meta1[sortBy])
|
||||||
|
}
|
||||||
|
if (swap) {
|
||||||
|
fileNodes[i] = pair2
|
||||||
|
fileNodes[i + 1] = pair1
|
||||||
|
node2.parentElement.insertBefore(node2, node1)
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} while (changed)
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("load", () => {
|
||||||
|
fetch("/api/files").then(res => {
|
||||||
|
return res.json()
|
||||||
|
}).then(files => {
|
||||||
|
addFiles(files)
|
||||||
|
sortFiles()
|
||||||
|
})
|
||||||
|
|
||||||
|
document.getElementById("sort-by").addEventListener("change", () => sortFiles())
|
||||||
|
document.getElementById("sort-desc").addEventListener("click", () => sortFiles())
|
||||||
|
document.getElementById("filter").addEventListener("change", () => sortFiles())
|
||||||
|
})
|
378
editor/public/static/js/integrity_manager.mjs
Normal file
@ -0,0 +1,378 @@
|
|||||||
|
import { Track } from "./tracks_table.mjs"
|
||||||
|
import { findLanguage, isLanguageTagAlias, flattenObj, updateObjectFromJoinedKey, LANGUAGES } from "./utils.mjs"
|
||||||
|
|
||||||
|
class Mismatch {
|
||||||
|
constructor(track, key, value) {
|
||||||
|
/** @type {Track} */
|
||||||
|
this.track = track
|
||||||
|
|
||||||
|
/** @type {string} */
|
||||||
|
this.key = key
|
||||||
|
this.value = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const CorrectionType = {
|
||||||
|
NOTHING: "nothing",
|
||||||
|
NAME: "name",
|
||||||
|
FIELD: "field"
|
||||||
|
}
|
||||||
|
|
||||||
|
const WORDS = {
|
||||||
|
forced: {
|
||||||
|
default: "Forced",
|
||||||
|
fre: "Forcés",
|
||||||
|
"fre-ca": "Forcés"
|
||||||
|
},
|
||||||
|
full: {
|
||||||
|
default: "Full",
|
||||||
|
fre: "Complets",
|
||||||
|
"fre-ca": "Complets"
|
||||||
|
},
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
function containsWord(parts, word) {
|
||||||
|
return Object.values(WORDS[word]).some(w => parts.includes(w))
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWord(word, lang) {
|
||||||
|
const words = WORDS[word]
|
||||||
|
return words[lang] ?? words.default
|
||||||
|
}
|
||||||
|
|
||||||
|
class MismatchCorrection {
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {Mismatch} mismatch
|
||||||
|
* @param {string} oldName
|
||||||
|
* @param {string} newName
|
||||||
|
*/
|
||||||
|
constructor(mismatch, oldName, newName) {
|
||||||
|
this.track = mismatch.track
|
||||||
|
this.key = mismatch.key
|
||||||
|
|
||||||
|
this.oldName = oldName
|
||||||
|
this.newName = newName
|
||||||
|
this.oldValue = this.track.fields[mismatch.key]
|
||||||
|
this.newValue = mismatch.value
|
||||||
|
}
|
||||||
|
|
||||||
|
apply(correctionType) {
|
||||||
|
switch (correctionType) {
|
||||||
|
case CorrectionType.NOTHING:
|
||||||
|
break
|
||||||
|
case CorrectionType.NAME:
|
||||||
|
this.track.editValue("name", this.newName)
|
||||||
|
break
|
||||||
|
case CorrectionType.FIELD:
|
||||||
|
this.track.editValue(this.key, this.newValue)
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
throw new Error(
|
||||||
|
`Invalid correction type, must be one of [${Object.values(CorrectionType)}], got ${correctionType}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class IntegrityManager {
|
||||||
|
IGNORE_KEYS = [
|
||||||
|
"type", "channels_details"
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {import('./editor.mjs').default} editor
|
||||||
|
*/
|
||||||
|
constructor(editor) {
|
||||||
|
this.editor = editor
|
||||||
|
|
||||||
|
/** @type {Mismatch[]} */
|
||||||
|
this.mismatches = []
|
||||||
|
|
||||||
|
/** @type {?MismatchCorrection} */
|
||||||
|
this.correction = null
|
||||||
|
|
||||||
|
this.ignoreList = []
|
||||||
|
|
||||||
|
this.popup = document.getElementById("integrity-popup")
|
||||||
|
|
||||||
|
this.popup.querySelectorAll(".correction .option").forEach(opt => {
|
||||||
|
const radio = opt.querySelector("input[type='radio']")
|
||||||
|
opt.addEventListener("click", e => {
|
||||||
|
radio.click()
|
||||||
|
})
|
||||||
|
radio.addEventListener("input", () => {
|
||||||
|
if (radio.checked) {
|
||||||
|
const prev = this.popup.querySelector(".option.selected input[type='radio']:not(:checked)")
|
||||||
|
prev.parentElement.classList.remove("selected")
|
||||||
|
opt.classList.add("selected")
|
||||||
|
} else {
|
||||||
|
opt.classList.remove("selected")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (radio.checked) {
|
||||||
|
opt.classList.add("selected")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
this.popup.querySelector("#int-apply").addEventListener("click", () => {
|
||||||
|
const fd = new FormData(this.popup.querySelector("form"))
|
||||||
|
const correctionType = fd.get("int-corr-type")
|
||||||
|
this.correct(correctionType)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
nextError() {
|
||||||
|
if (this.mismatches.length === 0) {
|
||||||
|
this.hideMismatchPopup()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const mismatch = this.mismatches.shift()
|
||||||
|
console.log("Next mismatch:", mismatch)
|
||||||
|
this.mismatches = this.mismatches.filter(m => m.track !== mismatch.track)
|
||||||
|
this.showMismatchPopup(mismatch)
|
||||||
|
}
|
||||||
|
|
||||||
|
checkIntegrity() {
|
||||||
|
this.ignoreList = []
|
||||||
|
this.mismatches = []
|
||||||
|
for (const table of Object.values(this.editor.tables)) {
|
||||||
|
this.checkTableIntegrity(table)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.mismatches.length === 0) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
this.nextError()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {import('./tracks_table.mjs').default} table
|
||||||
|
*/
|
||||||
|
checkTableIntegrity(table) {
|
||||||
|
for (const track of table.tracks) {
|
||||||
|
this.checkTrackIntegrity(track)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {Track} track
|
||||||
|
*/
|
||||||
|
checkTrackIntegrity(track, prepend=false) {
|
||||||
|
let fields = this.parseName(track.table.type, track.fields["name"])
|
||||||
|
fields = flattenObj(fields)
|
||||||
|
|
||||||
|
Object.entries(fields).map(([key, value]) => {
|
||||||
|
if (this.IGNORE_KEYS.includes(key)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (this.ignoreList.some(o => o.track === track && o.key === key)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let equal = track.fields[key] === value
|
||||||
|
if (key === "language") {
|
||||||
|
equal = isLanguageTagAlias(value, track.fields[key])
|
||||||
|
}
|
||||||
|
if (!equal) {
|
||||||
|
this.addMismatchField(track, key, value, prepend)
|
||||||
|
//console.error(`Mismatch for field ${key}:\n- name: ${value}\n- track: ${track.fields[key]}`)
|
||||||
|
} else {
|
||||||
|
track.fields[key] = value
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
parseName(trackType, name) {
|
||||||
|
/** @type {string} */
|
||||||
|
const lower = name.toLowerCase()
|
||||||
|
const parts = lower.split(/\b/)
|
||||||
|
const fields = {flags: {}}
|
||||||
|
switch (trackType) {
|
||||||
|
case "audio":
|
||||||
|
const audioLang = findLanguage(lower)
|
||||||
|
if (audioLang !== null) {fields.language = audioLang}
|
||||||
|
const original = parts.includes("vo")
|
||||||
|
if (original) {fields.flags.original = original}
|
||||||
|
const ad = parts.includes("ad")
|
||||||
|
if (ad) {fields.flags.visual_impaired = ad}
|
||||||
|
|
||||||
|
const channels = lower.match(/\d+\.\d+/)
|
||||||
|
if (channels) {
|
||||||
|
fields.channels_details = channels[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
break
|
||||||
|
|
||||||
|
case "subtitle":
|
||||||
|
const stLang = findLanguage(lower)
|
||||||
|
if (stLang !== null) {fields.language = stLang}
|
||||||
|
const isForced = containsWord(parts, "forced")
|
||||||
|
const isFull = containsWord(parts, "full")
|
||||||
|
if (isForced) {
|
||||||
|
fields.flags.forced = true
|
||||||
|
} else if (isFull) {
|
||||||
|
fields.flags.forced = false
|
||||||
|
}
|
||||||
|
const sdh = parts.includes("sdh")
|
||||||
|
if (sdh) {fields.flags.hearing_impaired = sdh}
|
||||||
|
|
||||||
|
if (parts.includes("pgs")) {
|
||||||
|
fields.type = "PGS"
|
||||||
|
} else {
|
||||||
|
fields.type = "SRT"
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return fields
|
||||||
|
}
|
||||||
|
|
||||||
|
addMismatchField(track, key, value, prepend=false) {
|
||||||
|
const mismatch = new Mismatch(track, key, value)
|
||||||
|
if (prepend) {
|
||||||
|
this.mismatches.splice(0, 0, mismatch)
|
||||||
|
} else {
|
||||||
|
this.mismatches.push(mismatch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {Mismatch} mismatch
|
||||||
|
*/
|
||||||
|
showMismatchPopup(mismatch) {
|
||||||
|
const q = sel => this.popup.querySelector(sel)
|
||||||
|
const track = mismatch.track
|
||||||
|
const table = track.table
|
||||||
|
const fieldProps = table.getFieldProps(mismatch.key)
|
||||||
|
const ogName = track.fields["name"]
|
||||||
|
const ogValue = track.fields[mismatch.key]
|
||||||
|
|
||||||
|
let fields = this.parseName(table.type, ogName)
|
||||||
|
let keys = Object.keys(flattenObj(fields))
|
||||||
|
|
||||||
|
Object.entries(flattenObj(track.fields)).forEach(([key, val]) => {
|
||||||
|
if (!keys.includes(key) || key == mismatch.key) {
|
||||||
|
updateObjectFromJoinedKey(fields, key, val)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const newName = this.reconstructName(table.type, fields)
|
||||||
|
|
||||||
|
this.correction = new MismatchCorrection(
|
||||||
|
mismatch, ogName, newName
|
||||||
|
)
|
||||||
|
|
||||||
|
let ogField = track.makeInput(fieldProps, ogValue, false)
|
||||||
|
let newField = track.makeInput(fieldProps, mismatch.value, false)
|
||||||
|
|
||||||
|
ogField = this.lockInput(ogField)
|
||||||
|
newField = this.lockInput(newField)
|
||||||
|
|
||||||
|
ogField.id = "int-og-field"
|
||||||
|
newField.id = "int-corr-new-field"
|
||||||
|
|
||||||
|
q("#int-track-type").innerText = table.type
|
||||||
|
q("#int-track-idx").innerText = track.idx
|
||||||
|
q("#int-field-name").innerText = fieldProps.name
|
||||||
|
q("#int-og-name").value = ogName
|
||||||
|
q("#int-og-field-name").innerText = fieldProps.name
|
||||||
|
q("#int-og-field").replaceWith(ogField)
|
||||||
|
q("#int-corr-new-name").value = newName
|
||||||
|
q("#int-corr-new-field-name").innerText = fieldProps.name
|
||||||
|
q("#int-corr-new-field").replaceWith(newField)
|
||||||
|
|
||||||
|
this.popup.classList.add("show")
|
||||||
|
}
|
||||||
|
|
||||||
|
reconstructName(tableType, fields) {
|
||||||
|
let name = LANGUAGES[fields.language].display
|
||||||
|
switch (tableType) {
|
||||||
|
case "audio":
|
||||||
|
if (fields.flags.original) {
|
||||||
|
name += " VO"
|
||||||
|
}/* else if (fields.language === "fre") {
|
||||||
|
name += " VFF"
|
||||||
|
} else if (fields.language === "fre-ca") {
|
||||||
|
name += " VFQ"
|
||||||
|
}*/
|
||||||
|
if (fields.flags.visual_impaired) {
|
||||||
|
name += " AD"
|
||||||
|
}
|
||||||
|
if (fields.channels_details) {
|
||||||
|
name += " / " + fields.channels_details
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case "subtitle":
|
||||||
|
if (fields.flags.forced) {
|
||||||
|
name += " " + getWord("forced", fields.language)
|
||||||
|
} else {
|
||||||
|
name += " " + getWord("full", fields.language)
|
||||||
|
}
|
||||||
|
if (fields.flags.hearing_impaired) {
|
||||||
|
name += " SDH"
|
||||||
|
}
|
||||||
|
name += " | " + fields.type
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
hideMismatchPopup() {
|
||||||
|
this.popup.classList.remove("show")
|
||||||
|
}
|
||||||
|
|
||||||
|
correct(correctionType) {
|
||||||
|
if (correctionType === CorrectionType.NOTHING) {
|
||||||
|
this.ignoreList.push({
|
||||||
|
track: this.correction.track,
|
||||||
|
key: this.correction.key
|
||||||
|
})
|
||||||
|
}
|
||||||
|
this.correction.apply(correctionType)
|
||||||
|
this.checkTrackIntegrity(this.correction.track, true)
|
||||||
|
this.nextError()
|
||||||
|
}
|
||||||
|
|
||||||
|
lockInput(input) {
|
||||||
|
input.readOnly = true
|
||||||
|
if (input.type === "checkbox") {
|
||||||
|
input.disabled = true
|
||||||
|
} else if (input.tagName === "SELECT") {
|
||||||
|
let text = document.createElement("div")
|
||||||
|
text.classList.add("select")
|
||||||
|
text.innerText = input.value
|
||||||
|
text.dataset.key = input.dataset.key
|
||||||
|
input = text
|
||||||
|
}
|
||||||
|
return input
|
||||||
|
}
|
||||||
|
|
||||||
|
improveAllNames() {
|
||||||
|
for (const table of Object.values(this.editor.tables)) {
|
||||||
|
for (const track of table.tracks) {
|
||||||
|
this.improveName(track)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {Track} track
|
||||||
|
*/
|
||||||
|
improveName(track) {
|
||||||
|
let nameFields = this.parseName(track.table.type, track.fields["name"])
|
||||||
|
const keys = Object.keys(flattenObj(nameFields))
|
||||||
|
Object.entries(flattenObj(track.fields)).forEach(([key, val]) => {
|
||||||
|
if (!keys.includes(key)) {
|
||||||
|
updateObjectFromJoinedKey(nameFields, key, val)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const name = this.reconstructName(track.table.type, nameFields)
|
||||||
|
track.editValue("name", name)
|
||||||
|
}
|
||||||
|
}
|
85
editor/public/static/js/metadata.mjs
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
export default class Metadata {
|
||||||
|
constructor(data) {
|
||||||
|
this.data = data
|
||||||
|
}
|
||||||
|
|
||||||
|
getData() {
|
||||||
|
return this.data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MediaMetadata extends Metadata {
|
||||||
|
constructor(data) {
|
||||||
|
super(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class EpisodeMetadata extends MediaMetadata {
|
||||||
|
REGEXP = /s(?<season>\d+)e(?<episode>\d+)/i
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {object} data
|
||||||
|
* @param {string} episodeKey
|
||||||
|
*/
|
||||||
|
constructor(data, episodeKey) {
|
||||||
|
super(data)
|
||||||
|
this.key = episodeKey
|
||||||
|
|
||||||
|
let m = this.key.match(this.REGEXP) ?? this.data.filename.match(this.REGEXP)
|
||||||
|
this.season = "xx"
|
||||||
|
this.episode = "xx"
|
||||||
|
if (m) {
|
||||||
|
this.season = m.groups.season
|
||||||
|
this.episode = m.groups.episode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SeriesMetadata extends Metadata {
|
||||||
|
constructor(data) {
|
||||||
|
super(data)
|
||||||
|
const episodeKeys = Object.keys(data).sort()
|
||||||
|
this.episodes = episodeKeys.map(key => {
|
||||||
|
return new EpisodeMetadata(data[key], key)
|
||||||
|
})
|
||||||
|
|
||||||
|
this.episodeIdx = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
getCurrentEpisode() {
|
||||||
|
return this.episodes[this.episodeIdx]
|
||||||
|
}
|
||||||
|
|
||||||
|
getData() {
|
||||||
|
return this.getCurrentEpisode().getData()
|
||||||
|
}
|
||||||
|
|
||||||
|
prev() {
|
||||||
|
if (this.episodeIdx === 0) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
this.episodeIdx -= 1
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
next() {
|
||||||
|
if (this.episodeIdx === this.episodes.length - 1) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
this.episodeIdx += 1
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {object} data
|
||||||
|
* @returns {Metadata}
|
||||||
|
*/
|
||||||
|
export function loadMetadata(data) {
|
||||||
|
if ("filename" in data) {
|
||||||
|
return new MediaMetadata(data)
|
||||||
|
}
|
||||||
|
return new SeriesMetadata(data)
|
||||||
|
}
|
248
editor/public/static/js/tracks_table.mjs
Normal file
@ -0,0 +1,248 @@
|
|||||||
|
import { findLanguage, flattenObj, getLanguageOptions } from "./utils.mjs"
|
||||||
|
|
||||||
|
export class Track {
|
||||||
|
constructor(table, idx, fields) {
|
||||||
|
/** @type {TracksTable} */
|
||||||
|
this.table = table
|
||||||
|
|
||||||
|
/** @type {number} */
|
||||||
|
this.idx = idx
|
||||||
|
|
||||||
|
/** @type {object} */
|
||||||
|
this.fields = flattenObj(fields)
|
||||||
|
|
||||||
|
this.row = null
|
||||||
|
}
|
||||||
|
|
||||||
|
makeRow() {
|
||||||
|
this.row = document.createElement("tr")
|
||||||
|
this.row.dataset.i = this.idx
|
||||||
|
this.table.fields.forEach(field => {
|
||||||
|
const td = this.row.insertCell(-1)
|
||||||
|
const input = this.makeInput(field, this.fields[field.key])
|
||||||
|
td.appendChild(input)
|
||||||
|
|
||||||
|
if (field.key === "name") {
|
||||||
|
const btn = document.getElementById("improve-btn").cloneNode(true)
|
||||||
|
btn.id = null
|
||||||
|
btn.classList.remove("template")
|
||||||
|
btn.addEventListener("click", () => {
|
||||||
|
this.improveName()
|
||||||
|
btn.classList.add("clicked")
|
||||||
|
setTimeout(() => btn.classList.remove("clicked"), 1000)
|
||||||
|
})
|
||||||
|
td.appendChild(btn)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return this.row
|
||||||
|
}
|
||||||
|
|
||||||
|
makeInput(field, value, listeners=true) {
|
||||||
|
let input = document.createElement("input")
|
||||||
|
let getValue = () => input.value
|
||||||
|
switch (field.type) {
|
||||||
|
case "num":
|
||||||
|
input.type = "number"
|
||||||
|
input.value = value
|
||||||
|
getValue = () => +input.value
|
||||||
|
break
|
||||||
|
|
||||||
|
case "str":
|
||||||
|
input.type = "text"
|
||||||
|
input.value = value
|
||||||
|
break
|
||||||
|
|
||||||
|
case "bool":
|
||||||
|
input.type = "checkbox"
|
||||||
|
|
||||||
|
getValue = () => input.checked
|
||||||
|
const onehot = this.table.CONSTRAINTS[field.key]?.type == "onehot"
|
||||||
|
|
||||||
|
if (listeners && onehot) {
|
||||||
|
if (value) {
|
||||||
|
if (field.key in this.table.onehots) {
|
||||||
|
this.table.editor.notify(
|
||||||
|
`Error in metadata file: field '${field.name}' is onehot but multiple tracks are enabled. Only the first one will be enabled`,
|
||||||
|
"error",
|
||||||
|
20000
|
||||||
|
)
|
||||||
|
value = false
|
||||||
|
} else {
|
||||||
|
this.table.onehots[field.key] = input
|
||||||
|
}
|
||||||
|
}
|
||||||
|
input.addEventListener("click", e => {
|
||||||
|
if (!input.checked) {
|
||||||
|
e.preventDefault()
|
||||||
|
} else {
|
||||||
|
if (field.key in this.table.onehots) {
|
||||||
|
this.table.onehots[field.key].checked = false
|
||||||
|
this.table.onehots[field.key].dispatchEvent(new Event("change"))
|
||||||
|
}
|
||||||
|
this.table.onehots[field.key] = input
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
input.checked = value
|
||||||
|
break
|
||||||
|
|
||||||
|
case "sel":
|
||||||
|
input = document.createElement("select")
|
||||||
|
let options = this.table.OPTIONS[field.key]
|
||||||
|
if (typeof options === "function") {
|
||||||
|
options = options()
|
||||||
|
}
|
||||||
|
options.forEach(option => {
|
||||||
|
const opt = document.createElement("option")
|
||||||
|
opt.innerText = option.display
|
||||||
|
opt.value = option.value
|
||||||
|
input.appendChild(opt)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (field.key === "language") {
|
||||||
|
const lang = findLanguage(value)
|
||||||
|
if (lang === null) {
|
||||||
|
this.table.editor.notify(
|
||||||
|
`Unknown language '${value}' for ${this.table.type} track ${this.idx}`,
|
||||||
|
"error",
|
||||||
|
20000
|
||||||
|
)
|
||||||
|
} else if (lang !== value) {
|
||||||
|
this.table.editor.notify(
|
||||||
|
`Language of ${this.table.type} track ${this.idx} was corrected (${value} -> ${lang})`,
|
||||||
|
"warning"
|
||||||
|
)
|
||||||
|
value = lang
|
||||||
|
this.editValue(field.key, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
input.value = value
|
||||||
|
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
input.name = field.key + "[]"
|
||||||
|
input.dataset.key = field.key
|
||||||
|
if (this.table.CONSTRAINTS[field.key]?.type === "readonly") {
|
||||||
|
input.disabled = true
|
||||||
|
}
|
||||||
|
if (listeners) {
|
||||||
|
input.addEventListener("change", () => {
|
||||||
|
this.editValue(field.key, getValue())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return input
|
||||||
|
}
|
||||||
|
|
||||||
|
editValue(key, value) {
|
||||||
|
this.fields[key] = value
|
||||||
|
this.table.editTrack(this.idx, key, value)
|
||||||
|
|
||||||
|
const input = this.row.querySelector(`[data-key='${key}']`)
|
||||||
|
if (input) {
|
||||||
|
const fieldType = this.table.getFieldProps(key).type
|
||||||
|
switch (fieldType) {
|
||||||
|
case "bool":
|
||||||
|
input.checked = value
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
input.value = value
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
improveName() {
|
||||||
|
this.table.editor.integrityMgr.improveName(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class TracksTable {
|
||||||
|
OPTIONS = {
|
||||||
|
"language": getLanguageOptions
|
||||||
|
}
|
||||||
|
CONSTRAINTS = {
|
||||||
|
"flags/default": {
|
||||||
|
type: "onehot"
|
||||||
|
},
|
||||||
|
"index": {
|
||||||
|
type: "readonly"
|
||||||
|
},
|
||||||
|
"channels": {
|
||||||
|
type: "readonly"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {import('./editor.mjs').default} editor The parent editor
|
||||||
|
* @param {string} type The type of tracks. One of `['audio', 'subtitle']`
|
||||||
|
* @param {string} tableId The id of the table element
|
||||||
|
* @param {string} dataKey The key of the tracks list inside of the data object
|
||||||
|
*/
|
||||||
|
constructor(editor, type, tableId, dataKey) {
|
||||||
|
this.editor = editor
|
||||||
|
this.type = type
|
||||||
|
this.table = document.getElementById(tableId)
|
||||||
|
this.headers = this.table.querySelector("thead tr")
|
||||||
|
this.body = this.table.querySelector("tbody")
|
||||||
|
this.dataKey = dataKey
|
||||||
|
|
||||||
|
this.tracks = []
|
||||||
|
this.fields = []
|
||||||
|
this.onehots = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
getFieldProps(key) {
|
||||||
|
return this.fields.find(f => f.key == key)
|
||||||
|
}
|
||||||
|
|
||||||
|
loadTracks(tracks) {
|
||||||
|
this.clear()
|
||||||
|
this.tracks = tracks.map((t, i) => new Track(this, i, t))
|
||||||
|
if (tracks.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.detectFields()
|
||||||
|
this.addHeaders()
|
||||||
|
this.tracks.forEach(track => {
|
||||||
|
this.body.appendChild(track.makeRow())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
clear() {
|
||||||
|
this.tracks = []
|
||||||
|
this.fields = []
|
||||||
|
this.onehots = {}
|
||||||
|
this.headers.innerHTML = ""
|
||||||
|
this.body.innerHTML = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
detectFields() {
|
||||||
|
Object.entries(this.tracks[0].fields).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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
editTrack(trackIdx, fieldKey, fieldValue) {
|
||||||
|
this.editor.editTrack(this.dataKey, trackIdx, fieldKey, fieldValue)
|
||||||
|
}
|
||||||
|
}
|
121
editor/public/static/js/utils.mjs
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
/**
|
||||||
|
* Flattens an object recursively. Nested keys are joined with slashes ('/')
|
||||||
|
* @param {object} obj The object to flatten
|
||||||
|
* @returns {object} The flattened object
|
||||||
|
*/
|
||||||
|
export 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Code (Flag): https://en.wikipedia.org/wiki/Regional_indicator_symbol
|
||||||
|
// Tag: https://en.wikipedia.org/wiki/List_of_ISO_639-2_codes
|
||||||
|
export const LANGUAGES = {
|
||||||
|
"fre-ca": {
|
||||||
|
display: "Français CA",
|
||||||
|
code: "ca",
|
||||||
|
aliases: ["fr-ca", "vfq", "quebec", "québec", "ca", "canada"]
|
||||||
|
},
|
||||||
|
"fre": {
|
||||||
|
display: "Français FR",
|
||||||
|
code: "fr",
|
||||||
|
aliases: ["fr", "fra", "french", "francais", "français", "vf", "vff", "france"]
|
||||||
|
},
|
||||||
|
"eng": {
|
||||||
|
display: "English",
|
||||||
|
code: "gb",
|
||||||
|
aliases: ["en", "ang", "english", "anglais", "uk", "gb", "usa", "british", "american", "amérique", "amerique", "angleterre", "royaume-uni"]
|
||||||
|
},
|
||||||
|
"deu": {
|
||||||
|
display: "Deutsch",
|
||||||
|
code: "de",
|
||||||
|
aliases: ["de", "ger", "german", "allemand", "deutsch", "germany", "allemagne"]
|
||||||
|
},
|
||||||
|
"ita": {
|
||||||
|
display: "Italiano",
|
||||||
|
code: "it",
|
||||||
|
aliases: ["it", "ita", "italian", "italien", "italiano", "italy", "italie"]
|
||||||
|
},
|
||||||
|
"kor": {
|
||||||
|
display: "Korean",
|
||||||
|
code: "kr",
|
||||||
|
aliases: ["ko", "kr", "cor", "korean", "coreen", "coréen", "corée", "coree", "korea"]
|
||||||
|
},
|
||||||
|
"jpn": {
|
||||||
|
display: "Japanese",
|
||||||
|
code: "jp",
|
||||||
|
aliases: ["ja", "jp", "jap", "japanese", "japonais", "japon", "japan"]
|
||||||
|
},
|
||||||
|
"tur": {
|
||||||
|
display: "Turkish",
|
||||||
|
code: "tr",
|
||||||
|
aliases: ["tu", "tr", "tür", "turkish", "turc", "turquie"]
|
||||||
|
},
|
||||||
|
"und": {
|
||||||
|
display: "Undefined",
|
||||||
|
code: "",
|
||||||
|
aliases: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLanguageAliases(langTag) {
|
||||||
|
return [langTag].concat(LANGUAGES[langTag].aliases)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tries to find a language name in the given string
|
||||||
|
* @param {string} value The string in which to search for a language
|
||||||
|
* @returns {?string} The language key if it could be determined, null otherwise
|
||||||
|
*/
|
||||||
|
export function findLanguage(value) {
|
||||||
|
for (const lang in LANGUAGES) {
|
||||||
|
const aliases = getLanguageAliases(lang)
|
||||||
|
const matches = aliases.some(a => {
|
||||||
|
return new RegExp("\\b" + a + "\\b").test(value)
|
||||||
|
})
|
||||||
|
if (matches) {
|
||||||
|
return lang
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isLanguageTagAlias(langTag, value) {
|
||||||
|
return getLanguageAliases(langTag).includes(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateObjectFromJoinedKey(obj, joinedKey, value) {
|
||||||
|
const keyParts = joinedKey.split("/")
|
||||||
|
for (const part of keyParts.slice(0, -1)) {
|
||||||
|
obj = obj[part]
|
||||||
|
}
|
||||||
|
obj[keyParts[keyParts.length - 1]] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} code
|
||||||
|
*/
|
||||||
|
export function makeFlag(code) {
|
||||||
|
return code.split("")
|
||||||
|
.map(c => String.fromCodePoint(
|
||||||
|
0x1f1e6 + c.codePointAt(0) - 97
|
||||||
|
))
|
||||||
|
.join("")
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLanguageOptions() {
|
||||||
|
return Object.entries(LANGUAGES).map(([tag, props]) => {
|
||||||
|
const flag = makeFlag(props.code)
|
||||||
|
return {value: tag, display: `${flag} ${props.display}`}
|
||||||
|
})
|
||||||
|
}
|
165
editor/server.py
Normal file → Executable file
@ -1,15 +1,40 @@
|
|||||||
from http import HTTPStatus
|
#!/usr/bin/env python3
|
||||||
from http.server import SimpleHTTPRequestHandler
|
|
||||||
|
import argparse
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import socketserver
|
import socketserver
|
||||||
|
from http import HTTPStatus
|
||||||
|
from http.server import SimpleHTTPRequestHandler
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from urllib.parse import urlparse, parse_qs
|
from urllib.parse import parse_qs, unquote, urlparse
|
||||||
|
|
||||||
|
|
||||||
|
# https://stackoverflow.com/a/10551190/11109181
|
||||||
|
class EnvDefault(argparse.Action):
|
||||||
|
def __init__(self, envvar, required=True, default=None, help=None, **kwargs):
|
||||||
|
if envvar:
|
||||||
|
if envvar in os.environ:
|
||||||
|
default = os.environ[envvar]
|
||||||
|
if required and default is not None:
|
||||||
|
required = False
|
||||||
|
|
||||||
|
if default is not None and help is not None:
|
||||||
|
help += f" (default: {default})"
|
||||||
|
|
||||||
|
if envvar and help is not None:
|
||||||
|
help += f"\nCan also be specified through the {envvar} environment variable"
|
||||||
|
super(EnvDefault, self).__init__(default=default, required=required, help=help,
|
||||||
|
**kwargs)
|
||||||
|
|
||||||
|
def __call__(self, parser, namespace, values, option_string=None):
|
||||||
|
setattr(namespace, self.dest, values)
|
||||||
|
|
||||||
PORT = 8000
|
|
||||||
|
|
||||||
class MyHandler(SimpleHTTPRequestHandler):
|
class MyHandler(SimpleHTTPRequestHandler):
|
||||||
|
MAX_PAYLOAD_SIZE = 1e6
|
||||||
DATA_DIR = "metadata"
|
DATA_DIR = "metadata"
|
||||||
|
CACHE = {}
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(
|
super().__init__(
|
||||||
@ -21,9 +46,13 @@ class MyHandler(SimpleHTTPRequestHandler):
|
|||||||
self.data: Optional[dict|list] = None
|
self.data: Optional[dict|list] = None
|
||||||
|
|
||||||
def read_body_data(self):
|
def read_body_data(self):
|
||||||
self.log_message("Reading body data")
|
|
||||||
try:
|
try:
|
||||||
raw_data = self.rfile.read(int(self.headers["Content-Length"]))
|
size: int = int(self.headers["Content-Length"])
|
||||||
|
if size > self.MAX_PAYLOAD_SIZE:
|
||||||
|
self.send_error(HTTPStatus.CONTENT_TOO_LARGE)
|
||||||
|
self.log_error(f"Payload is too big ({self.MAX_PAYLOAD_SIZE=}B)")
|
||||||
|
return False
|
||||||
|
raw_data = self.rfile.read(size)
|
||||||
self.data = json.loads(raw_data)
|
self.data = json.loads(raw_data)
|
||||||
except:
|
except:
|
||||||
self.send_error(HTTPStatus.NOT_ACCEPTABLE, "Malformed JSON body")
|
self.send_error(HTTPStatus.NOT_ACCEPTABLE, "Malformed JSON body")
|
||||||
@ -32,6 +61,7 @@ class MyHandler(SimpleHTTPRequestHandler):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
def do_GET(self):
|
def do_GET(self):
|
||||||
|
self.path = unquote(self.path)
|
||||||
self.query = parse_qs(urlparse(self.path).query)
|
self.query = parse_qs(urlparse(self.path).query)
|
||||||
if self.path.startswith("/api/"):
|
if self.path.startswith("/api/"):
|
||||||
self.handle_api_get(self.path.removeprefix("/api/").removesuffix("/"))
|
self.handle_api_get(self.path.removeprefix("/api/").removesuffix("/"))
|
||||||
@ -39,6 +69,7 @@ class MyHandler(SimpleHTTPRequestHandler):
|
|||||||
super().do_GET()
|
super().do_GET()
|
||||||
|
|
||||||
def do_POST(self):
|
def do_POST(self):
|
||||||
|
self.path = unquote(self.path)
|
||||||
self.query = parse_qs(urlparse(self.path).query)
|
self.query = parse_qs(urlparse(self.path).query)
|
||||||
if self.path.startswith("/api/"):
|
if self.path.startswith("/api/"):
|
||||||
self.handle_api_post(self.path.removeprefix("/api/").removesuffix("/"))
|
self.handle_api_post(self.path.removeprefix("/api/").removesuffix("/"))
|
||||||
@ -46,25 +77,34 @@ class MyHandler(SimpleHTTPRequestHandler):
|
|||||||
self.send_error(HTTPStatus.NOT_FOUND)
|
self.send_error(HTTPStatus.NOT_FOUND)
|
||||||
|
|
||||||
def handle_api_get(self, path: str):
|
def handle_api_get(self, path: str):
|
||||||
print(f"API request at {path}")
|
self.log_message(f"API request at {path}")
|
||||||
if path == "files":
|
if path == "files":
|
||||||
files: list[str] = self.get_files()
|
files: list[str] = self.get_files_meta()
|
||||||
self.send_json(files)
|
self.send_json(files)
|
||||||
return
|
elif path.startswith("file"):
|
||||||
|
filename: str = path.split("/", 1)[1]
|
||||||
def handle_api_post(self, path: str):
|
data = self.read_file(filename)
|
||||||
if path == "file":
|
|
||||||
if self.read_body_data():
|
|
||||||
data = self.get_file(self.data["file"])
|
|
||||||
if data is None:
|
if data is None:
|
||||||
self.send_error(HTTPStatus.NOT_FOUND)
|
self.send_error(HTTPStatus.NOT_FOUND)
|
||||||
self.log_message("File not found")
|
|
||||||
else:
|
else:
|
||||||
self.log_message("Got file")
|
|
||||||
self.send_json(data)
|
self.send_json(data)
|
||||||
|
else:
|
||||||
|
self.send_response(HTTPStatus.NOT_FOUND, f"Unknown path {path}")
|
||||||
|
self.end_headers()
|
||||||
|
|
||||||
|
def handle_api_post(self, path: str):
|
||||||
|
if path.startswith("file"):
|
||||||
|
if self.read_body_data():
|
||||||
|
filename: str = path.split("/", 1)[1]
|
||||||
|
if self.write_file(filename, self.data):
|
||||||
|
self.send_response(HTTPStatus.OK)
|
||||||
|
self.end_headers()
|
||||||
|
else:
|
||||||
|
self.send_response(HTTPStatus.NOT_FOUND, f"Unknown path {path}")
|
||||||
|
self.end_headers()
|
||||||
|
|
||||||
def send_json(self, data: dict|list):
|
def send_json(self, data: dict|list):
|
||||||
self.send_response(200)
|
self.send_response(HTTPStatus.OK)
|
||||||
self.send_header("Content-Type", "application/json")
|
self.send_header("Content-Type", "application/json")
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
self.wfile.write(json.dumps(data).encode("utf-8"))
|
self.wfile.write(json.dumps(data).encode("utf-8"))
|
||||||
@ -72,17 +112,102 @@ class MyHandler(SimpleHTTPRequestHandler):
|
|||||||
def get_files(self):
|
def get_files(self):
|
||||||
return os.listdir(self.DATA_DIR)
|
return os.listdir(self.DATA_DIR)
|
||||||
|
|
||||||
def get_file(self, filename: str) -> Optional[dict|list]:
|
def read_file(self, filename: str) -> Optional[dict|list]:
|
||||||
if filename not in self.get_files():
|
if filename not in self.get_files():
|
||||||
return None
|
return None
|
||||||
with open(os.path.join(self.DATA_DIR, filename), "r") as f:
|
with open(os.path.join(self.DATA_DIR, filename), "r") as f:
|
||||||
data = json.load(f)
|
data = json.load(f)
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
def write_file(self, filename: str, data: dict|list) -> bool:
|
||||||
|
if filename not in self.get_files():
|
||||||
|
self.send_error(HTTPStatus.NOT_FOUND)
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(os.path.join(self.DATA_DIR, filename), "w", encoding="utf-8") as f:
|
||||||
|
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||||
|
except:
|
||||||
|
self.send_error(HTTPStatus.INTERNAL_SERVER_ERROR)
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def get_files_meta(self):
|
||||||
|
files: list[str] = self.get_files()
|
||||||
|
files_meta: list[dict] = []
|
||||||
|
|
||||||
|
deleted = set(self.CACHE.keys()) - set(files)
|
||||||
|
for filename in deleted:
|
||||||
|
del self.CACHE[deleted]
|
||||||
|
|
||||||
|
for filename in files:
|
||||||
|
path: str = os.path.join(self.DATA_DIR, filename)
|
||||||
|
last_modified: float = os.path.getmtime(path)
|
||||||
|
if filename not in self.CACHE or self.CACHE[filename]["ts"] < last_modified:
|
||||||
|
self.update_file_meta(filename)
|
||||||
|
|
||||||
|
files_meta.append(self.CACHE[filename])
|
||||||
|
|
||||||
|
return files_meta
|
||||||
|
|
||||||
|
def update_file_meta(self, filename: str):
|
||||||
|
path: str = os.path.join(self.DATA_DIR, filename)
|
||||||
|
|
||||||
|
meta = {
|
||||||
|
"filename": filename,
|
||||||
|
"ts": os.path.getmtime(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
with open(path, "r") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
is_series = "filename" not in data
|
||||||
|
meta["type"] = "series" if is_series else "film"
|
||||||
|
if is_series:
|
||||||
|
meta["episodes"] = len(data)
|
||||||
|
meta["title"] = filename.split("_metadata")[0]
|
||||||
|
else:
|
||||||
|
meta["title"] = data["title"]
|
||||||
|
|
||||||
|
self.CACHE[filename] = meta
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
with socketserver.TCPServer(("", PORT), MyHandler) as httpd:
|
parser = argparse.ArgumentParser(
|
||||||
print(f"Serving on port {PORT}")
|
description="Starts the Melies server",
|
||||||
|
formatter_class=argparse.RawTextHelpFormatter
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-p", "--port",
|
||||||
|
action=EnvDefault,
|
||||||
|
envvar="MELIES_PORT",
|
||||||
|
default=8000,
|
||||||
|
type=int,
|
||||||
|
help="Port on which the server listens"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--max-payload-size",
|
||||||
|
action=EnvDefault,
|
||||||
|
envvar="MELIES_MAX_PAYLOAD_SIZE",
|
||||||
|
default=1e6,
|
||||||
|
type=int,
|
||||||
|
help="Maximum POST payload size in bytes that the server accepts"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--metadata-dir",
|
||||||
|
action=EnvDefault,
|
||||||
|
envvar="MELIES_METADATA_DIR",
|
||||||
|
default="metadata",
|
||||||
|
help="Path to the directory containing metadata files"
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
port = args.port
|
||||||
|
MyHandler.MAX_PAYLOAD_SIZE = args.max_payload_size
|
||||||
|
MyHandler.DATA_DIR = args.metadata_dir
|
||||||
|
|
||||||
|
with socketserver.TCPServer(("", port), MyHandler) as httpd:
|
||||||
|
print(f"Serving on port {port}")
|
||||||
httpd.serve_forever()
|
httpd.serve_forever()
|
||||||
|
|
||||||
|
|
||||||
|