Initial commit

This commit is contained in:
2025-10-06 13:24:56 +02:00
commit b50be8ab4b
11 changed files with 971 additions and 0 deletions

6
README.md Normal file
View File

@@ -0,0 +1,6 @@
# THIS WILL NOT WORK IF YOU RUN IT YOURSELF!
The generator expects `template.zip` with the full template included, it just injects its own `thesis.typ` into it.
I'll make the script for generating this archive when I get back from work today lol.

167
data.js Normal file
View File

@@ -0,0 +1,167 @@
let steps = {
faculty: {
title: "Pro kterou fakultu budete svou práci tvořit?",
layout: faculty_layout,
result: {
name: ""
}
},
thesis_type: {
title: "Jakou práci budete tvořit?",
layout: thesis_layout,
result: {
type: ""
}
},
author_info: {
title: "Vaše údaje",
layout: author_info_layout,
result: {
name: "",
gender: ""
}
},
collaborators: {
title: "Vedoucí práce",
layout: collaborators_layout,
result: {
has_supervisor: true,
supervisor_name: "",
has_consultant: false,
consultant_name: ""
}
},
}
const faculties = {
"fs": {
name: "Fakulta strojní"
},
"ft": {
name: "Fakulta textilní"
},
"fp": {
name: "Fakulta přirodovědně-humanitní a pedagogická"
},
"ef": {
name: "Ekonomická fakulta"
},
"fua": {
name: "Fakulta umění a architektury"
},
"fm": {
name: "Fakulta mechatroniky, informatiky a mezioborových studií"
},
"fzs": {
name: "Fakulta zdravotnických studií"
},
"cxi": {
name: "Ústav pro nanomateriály, pokročilé technologie a inovace"
}
}
const theses = {
"bp": {
name: "Bakalářská práce"
},
"dp": {
name: "Diplomová práce"
},
"dis": {
name: "Disertační práce"
},
"hab": {
name: "Habilitační práce"
},
"teze": {
name: "Teze disertační práce"
},
"autoref": {
name: "Autoreferát disertační práce"
},
"proj": {
name: "Projekt"
},
"sp": {
name: "Semestrální práce"
}
}
const genders = {
"masculine": {
name: "Mužský rod"
},
"feminine": {
name: "Ženský rod"
},
"we": {
name: "Množné číslo"
}
}
const typst_header = `#import "template/template.typ": *
#show: tultemplate2.with(
faculty: "{faculty.name}",
document: "{thesis_type.type}",
title: (cs: "Návod na použití Typst TUL šablony"),
author: "{author_info.name}",
author_gender: "{author_info.gender}",
<collaborators.has_supervisor: supervisor: "{collaborators.supervisor_name}",
><collaborators.has_consultant: consultant: "{collaborators.consultant_name}",
> citations: "citations.bib",
assignment: "zadani.pdf"
)
= Nadpis
skibidi
`;
async function generate_zip() {
let assets_zip = await fetch("template.zip");
let out_zip = await new JSZip().loadAsync(await assets_zip.blob());
let out = typst_header;
for(const step_key in steps) {
console.log(step_key);
for(const key in steps[step_key].result) {
console.log("- " + key);
const search_condition = "<" + step_key + "." + key + ":";
const condition_pos = out.search(search_condition);
if(condition_pos >= 0) {
const condition_end_pos = out.slice(condition_pos).search(">");
if(condition_end_pos >= 0) {
const head = out.slice(0, condition_pos);
const meat = steps[step_key].result[key] ? out.slice(condition_pos + search_condition.length, condition_pos + condition_end_pos) : "";
const tail = out.slice(condition_pos + condition_end_pos + 1);
out = head + meat + tail;
}
}
const search_replacement = "{" + step_key + "." + key + "}";
const replacement_pos = out.search(search_replacement);
if(replacement_pos >= 0) {
const head = out.slice(0, replacement_pos);
const meat = steps[step_key].result[key];
const tail = out.slice(replacement_pos + search_replacement.length);
out = head + meat + tail;
}
}
}
console.log(out);
out_zip.file("thesis.typ", out);
return await out_zip.generateAsync({ type: "blob" });
}

39
index.html Normal file
View File

@@ -0,0 +1,39 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="styles.css">
<script type="text/javascript" src="lib/jszip.min.js"></script>
<script type="text/javascript" src="ui.js"></script>
<script type="text/javascript" src="utils.js"></script>
<script type="text/javascript" src="data.js"></script>
<title>TUL Typst template generator</title>
</head>
<body>
<div class="main-container">
<div class="main-content">
<h1>Generátor TUL Typst šablony</h1>
<img style="height: 100px; width: 100px;" src="tul_prdel.svg">
<div class="paragraphs">
<p>
Vítejte v generátoru šablony pro tvorbu prací v jazyce Typst.
</p>
<p>
Zeptáme se Vás na několik základních otázek ohledně Vaší práce
a na konci poskytneme funkční práci v Typstu s Vámi zadanými údaji.
</p>
</div>
<div class="horizontal-list">
<button class="accent" id="startbutton" onclick="run();">Jdeme na to!</div>
</div>
</div>
</div>
<img class="zmp-logo" src="zumepro_dark_full.svg">
</body>
</html>

13
lib/jszip.min.js vendored Normal file

File diff suppressed because one or more lines are too long

300
styles.css Normal file
View File

@@ -0,0 +1,300 @@
:root {
--tul-color: #5948AD;
--fs-color: #888B95;
--ft-color: #924C14;
--fp-color: #0076D5;
--ef-color: #65A812;
--fua-color: #006443;
--fm-color: #EA7603;
--fzs-color: #00B0BE;
--cxi-color: #C20019;
--accent-color: var(--tul-color);
--background-color: color-mix(in srgb, var(--accent-color) 5%, white);
--shadow-color: color-mix(in srgb, var(--accent-color), gray);
--button-color: var(--background-color);
--button-hover-color: white;
--button-border-color: color-mix(in srgb, var(--accent-color), white);
--button-accent-color: var(--accent-color);
--button-accent-hover-color: color-mix(in srgb, var(--accent-color), white 25%);
}
[data-faculty= "fs"] { --accent-color: var( --fs-color); }
[data-faculty= "ft"] { --accent-color: var( --ft-color); }
[data-faculty= "fp"] { --accent-color: var( --fp-color); }
[data-faculty= "ef"] { --accent-color: var( --ef-color); }
[data-faculty="fua"] { --accent-color: var(--fua-color); }
[data-faculty= "fm"] { --accent-color: var( --fm-color); }
[data-faculty="fzs"] { --accent-color: var(--fzs-color); }
[data-faculty="cxi"] { --accent-color: var(--cxi-color); }
html, body {
width: 100%;
height: 100%;
margin: 0;
font-family: "Noto Sans", sans-serif;
font-size: 20px;
}
body {
display: flex;
align-items: center;
justify-content: center;
background-color: var(--background-color);
transition: background-color 0.25s;
}
.main-container {
padding: 24px;
width: 600px;
height: 500px;
box-shadow: 0 0 20px var(--shadow-color);
background-color: white;
border-radius: 16px;
transition: box-shadow 0.25s;
}
.main-content {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-between;
text-align: center;
opacity: 1;
transition: opacity 0.25s;
}
.main-content > * {
flex-grow: 0;
}
h1 {
margin: 0;
font-weight: bold;
text-align: center;
font-size: 32px;
}
.paragraphs {
display: flex;
flex-direction: column;
gap: 16px;
}
.paragraphs > p {
margin: 0;
}
a, a:visited, a:active {
color: black;
}
.horizontal-list {
width: 100%;
display: flex;
flex-direction: row;
justify-content: center;
gap: 8px;
}
.horizontal-list > .filler {
flex-grow: 1;
}
button, .button {
box-sizing: border-box;
flex-grow: 0;
padding: 8px 16px;
text-decoration: none;
font-family: inherit;
font-size: 100%;
border: 1px var(--button-border-color) solid;
color: black;
background-color: var(--button-color);
border-radius: 8px;
cursor: pointer;
transition: color 0.25s, background-color 0.25s, border 0.25s;
}
button:hover, .button:hover {
background-color: white;
}
button.narrow, .button.narrow {
padding: 4px 16px;
font-size: 16px;
width: 100%;
text-align: left;
}
button.expand, .button.expand {
flex-grow: 2;
flex-basis: 0;
}
button.accent, .button.accent {
color: white;
border: 1px var(--button-accent-color) solid;
background-color: var(--button-accent-color);
}
button.accent:hover, .button.accent:hover {
background-color: var(--button-accent-hover-color);
border: 1px var(--button-accent-hover-color) solid;
}
button:disabled, .button:disabled {
border: 1px gray solid;
background-color: lightgray;
color: black;
cursor: not-allowed;
}
button:disabled:hover, .button:disabled:hover {
border: 1px gray solid;
background-color: lightgray;
color: black;
}
.vertical-spacer {
height: 16px
}
.vertical-list {
flex-grow: 1;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.slide-animation {
position: relative;
opacity: 1;
left: 0;
transition: left 0.25s, opacity 0.25s;
}
.slide-from-left {
animation: 0.25s forwards from-left;
}
.slide-from-right {
animation: 0.25s forwards from-right;
}
.slide-to-left {
animation: 0.25s forwards to-left;
}
.slide-to-right {
animation: 0.25s forwards to-right;
}
@keyframes from-left {
from {
opacity: 0;
left: -16px;
}
to {
opacity: 1;
left: 0;
}
}
@keyframes from-right {
from {
opacity: 0;
left: 16px;
}
to {
opacity: 1;
left: 0;
}
}
@keyframes to-left {
from {
opacity: 1;
left: 0;
}
to {
opacity: 0;
left: -16px;
}
}
@keyframes to-right {
from {
opacity: 1;
left: 0;
}
to {
opacity: 0;
left: 16px;
}
}
input[type="checkbox"] {
width: 16px;
margin: 0;
}
label {
flex-grow: 1;
text-align: left;
user-select: none;
}
input[type="text"] {
flex-grow: 0;
padding: 8px 16px;
font-family: inherit;
font-size: 100%;
border: 1px var(--button-border-color) solid;
color: black;
background-color: var(--button-color);
border-radius: 8px;
box-sizing: border-box;
width: 100%;
}
input[type="text"]::placeholder {
color: gray;
}
input[type="text"]:disabled::placeholder {
color: lightgray;
}
input[type="text"]:disabled {
background-color: white;
color: lightgray;
}
.zmp-logo {
position: absolute;
bottom: 20px;
height: 1em;
}

1
tul_prdel.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.1 KiB

255
ui.js Normal file
View File

@@ -0,0 +1,255 @@
let tul_prdel = null;
function get_step(id) {
return steps[Object.keys(steps)[id]];
}
async function faculty_layout(content, result) {
if(!Object.keys(faculties).includes(result.name)) {
document.querySelector("#next-button").disabled = true;
}
let buttons = new ButtonListBuilder(Object.keys(faculties))
.should_be_pressed((key) => result.name == key)
.modify((b, key) => b.class("narrow").text(faculties[key].name))
.on_click((key) => {
result.name = key;
document.querySelector("#next-button").disabled = false;
document.documentElement.setAttribute("data-faculty", key);
})
.finish();
for(const button of buttons) {
content.appendChild(button);
}
}
async function thesis_layout(content, result) {
if(!Object.keys(theses).includes(result.type)) {
document.querySelector("#next-button").disabled = true;
}
let buttons = new ButtonListBuilder(Object.keys(theses))
.should_be_pressed((key) => result.type == key)
.modify((b, key) => b.class("narrow").text(theses[key].name))
.on_click((key) => {
result.type = key;
document.querySelector("#next-button").disabled = false;
})
.finish();
for(const button of buttons) {
content.appendChild(button);
}
}
async function author_info_layout(content, result) {
let author_name = new ElementBuilder("input")
.type("text")
.placeholder("Vaše jméno (vč. titulů), příp. jména všech autorů")
.on_input((e) => result.name = e.target.value)
.finish();
if(!Object.keys(genders).includes(result.gender)) {
document.querySelector("#next-button").disabled = true;
}
let gender_buttons = new ButtonListBuilder(Object.keys(genders))
.should_be_pressed((key) => result.gender == key)
.modify((b, key) => b.class("expand").text(genders[key].name))
.on_click((key) => {
result.gender = key;
document.querySelector("#next-button").disabled = false;
})
.finish();
content.appendChild(author_name);
content.appendChild(new ElementBuilder("div.vertical-spacer").finish());
content.appendChild(new ElementBuilder("div")
.text("Způsob sebeoslovení")
.finish());
content.appendChild(new ElementBuilder("div.horizontal-list")
.append_all(gender_buttons)
.finish());
}
async function collaborators_layout(content, result) {
let supervisor_name = new ElementBuilder("input")
.type("text")
.placeholder("Jméno vedoucího práce (vč. titulů), příp. vedoucích")
.on_input((e) => result.supervisor_name = e.target.value)
.finish();
let consultant_name = new ElementBuilder("input")
.type("text")
.placeholder("Jméno konzultanta práce (vč. titulů), příp. konzultantů")
.on_input((e) => result.supervisor_name = e.target.value)
.finish();
let has_supervisor = new ElementBuilder("input#has-supervisor")
.type("checkbox")
.checked(result.has_supervisor)
.on_click(() => update())
.finish();
let has_consultant = new ElementBuilder("input#has-consultant")
.type("checkbox")
.checked(result.has_consultant)
.on_click(() => update())
.finish();
function update() {
supervisor_name.disabled = !has_supervisor.checked;
consultant_name.disabled = !has_consultant.checked;
result.has_supervisor = has_supervisor.checked;
result.has_consultant = has_consultant.checked;
}
update();
content.appendChild(new ElementBuilder("div.horizontal-list")
.append(has_supervisor)
.append(new ElementBuilder("label")
.for("has-supervisor")
.text("Práce má vedoucího"))
.finish());
content.appendChild(supervisor_name);
content.appendChild(new ElementBuilder("div.vertical-spacer").finish());
content.appendChild(new ElementBuilder("div.horizontal-list")
.append(has_consultant)
.append(new ElementBuilder("label")
.for("has-consultant")
.text("Práce má konzultanta"))
.finish());
content.appendChild(consultant_name);
}
async function run() {
startbutton.style.cursor = "auto";
startbutton.disabled = true;
let main_container = document.querySelector(".main-container");
let start_content = main_container.querySelector(".main-content");
start_content.style.opacity = 0;
await delay_ms(300);
start_content.remove();
let container = new ElementBuilder("div.vertical-list")
.style("justifyContent", "space-between")
.finish();
let back_button = new ElementBuilder("button#back-button")
.text("← Zpět")
.finish();
let next_button = new ElementBuilder("button.accent#next-button").finish();
let main_content = new ElementBuilder("div.main-content")
.style("opacity", 0)
.append(container)
.append(new ElementBuilder("div.horizontal-list")
.append(back_button)
.append(new ElementBuilder("div.filler"))
.append(next_button))
.finish();
main_container.appendChild(main_content);
await delay_ms(100);
main_content.style.opacity = 1;
let id = 0;
let animation = 0;
let steps_len = Object.keys(steps).length;
while(id < steps_len) {
back_button.style.display = (id != 0) ? "" : "none";
next_button.innerText = (id < steps_len - 1) ? "Další →" : "Dokončit";
next_button.disabled = false;
container.innerHTML = "";
let step = get_step(id);
let title = new ElementBuilder("h1")
.html(step.title)
.finish();
let content = new ElementBuilder("div.vertical-list")
.style("gap", "12px")
.finish();
let subcontainer = new ElementBuilder("div.vertical-list.slide-animation")
.append(title)
.append(content)
.if(animation < 0, c => c.class("slide-from-left"))
.if(animation > 0, c => c.class("slide-from-right"))
.finish();
await step.layout(content, step.result);
container.appendChild(subcontainer);
animation = 0;
let new_id = await new Promise((r) => {
back_button.onclick = () => {
r(id - 1);
}
next_button.onclick = () => {
r(id + 1);
}
});
if(new_id < steps_len) {
subcontainer.classList.add((new_id > id) ? "slide-to-left" : "slide-to-right");
await delay_ms(250);
subcontainer.remove();
animation = new_id - id;
}
id = new_id;
}
main_content.style.opacity = 0;
let [_, out_blob] = await Promise.all([delay_ms(300), generate_zip()])
main_content.remove();
let color = window.getComputedStyle(document.body).getPropertyValue("--" + steps.faculty.result.name + "-color");
let logo = tul_prdel.replaceAll('fill="#5948ad"', 'fill="' + color + '"');
main_content = new ElementBuilder("div.main-content")
.style("opacity", 0)
.append(new ElementBuilder("h1").text("Vaše šablona je připravena!"))
.append(new ElementBuilder("img")
.src(URL.createObjectURL(new Blob([logo], { type: "image/svg+xml" })))
.style("width", "100px")
.style("height", "100px"))
.append(new ElementBuilder("div.paragraphs")
.append(new ElementBuilder("p").html(`
Po stažení archivu můžete šablonu rozbalit a
buď ji otevřít ve svém oblíbeném textovém editoru,
nebo ji nahrát do webového rozhraní Typstu.
`))
.append(new ElementBuilder("p").append(new ElementBuilder("a")
.text("Webové rozhraní Typstu otevřete kliknutím zde.")
.href("https://typst.app")
.target("_blank"))))
.append(new ElementBuilder("a.button.accent")
.href(URL.createObjectURL(out_blob))
.download("tul-thesis-typst.zip")
.text("Klikněte zde pro stažení archivu"))
.finish();
main_container.appendChild(main_content);
await delay_ms(100);
main_content.style.opacity = 1;
}
window.onload = async () => {
let img = await fetch("tul_prdel.svg"); // Should already be cached by now (hopefully)
tul_prdel = await img.text();
}

187
utils.js Normal file
View File

@@ -0,0 +1,187 @@
class ElementBuilder {
constructor(tag) {
let parts = tag.split(/(\.|#)/);
if(parts.length % 2 == 0) {
throw new Error("invalid element builder tag string");
}
this.element = document.createElement(parts[0]);
parts = parts.slice(1);
for(let i = 0; i < parts.length; i += 2) {
let type = parts[i];
let value = parts[i + 1];
switch(type) {
case ".":
this.element.classList.add(value);
break;
case "#":
this.element.id = value;
break;
}
}
}
// Need more stuff? Just implement it here.
id(id) {
this.element.id = id;
return this;
}
class(clazz) {
for(const subclass of clazz.split(" ").filter(x => x !== "")) {
this.element.classList.add(subclass);
}
return this;
}
text(text) {
this.element.innerText = text;
return this;
}
html(html) {
this.element.innerHTML = html;
return this;
}
on_click(handler) {
this.element.onclick = handler;
return this;
}
on_input(handler) {
this.element.oninput = handler;
return this;
}
for(fr) {
this.element.htmlFor = fr;
return this;
}
src(src) {
this.element.src = src;
return this;
}
target(target) {
this.element.target = target;
return this;
}
href(href) {
this.element.href = href;
return this;
}
download(download) {
this.element.download = download;
return this;
}
type(type) {
this.element.type = type;
return this;
}
checked(checked) {
this.element.checked = checked;
return this;
}
placeholder(placeholder) {
this.element.placeholder = placeholder;
return this;
}
append(element) {
if(element instanceof ElementBuilder) {
element = element.finish();
}
this.element.appendChild(element);
return this;
}
append_all(elements) {
for(const element of elements) {
this.element.appendChild(element);
}
return this;
}
style(name, value) {
this.element.style[name] = value;
return this;
}
if(cond, handler) {
if(cond) handler(this);
return this;
}
finish() {
return this.element;
}
}
class ButtonListBuilder {
constructor(list) {
this.list = list;
this.display_handler = null;
this.onclick_handler = null;
this.modify_handler = null;
}
should_be_pressed(handler) {
this.display_handler = handler;
return this;
}
on_click(handler) {
this.onclick_handler = handler;
return this;
}
modify(handler) {
this.modify_handler = handler;
return this;
}
finish() {
let buttons = this.list.map((key) => new ElementBuilder("button")
.if(this.modify_handler !== null, (b) => this.modify_handler(b, key))
.if(this.display_handler(key), (b) => b.class("accent"))
.finish()
);
for(let i = 0; i < buttons.length; i++) {
buttons[i].onclick = () => {
if(this.onclick_handler !== null) this.onclick_handler(this.list[i]);
for(let j = 0; j < buttons.length; j++) {
buttons[j].classList.remove("accent");
if(this.display_handler(this.list[j])) {
buttons[j].classList.add("accent");
}
}
};
}
return buttons;
}
}
async function delay_ms(ms) {
await new Promise((r) => setTimeout(r, ms));
}

1
zumepro_dark_full.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.1 KiB

1
zumepro_dark_mark.svg Normal file
View File

@@ -0,0 +1 @@
<svg height="14.863209mm" viewBox="0 0 44.41584 14.863209" width="44.41584mm" xmlns="http://www.w3.org/2000/svg"><g fill-rule="evenodd" transform="matrix(-.63629813 0 0 -.63629813 63.79826 74.07681)"><path d="m-11.759578-96.806604v-3.098032q-2.223016-.189194-3.500067-1.418944-1.253402-1.2534-1.3007-3.28723h2.128418q0 1.15881.685824 1.89193.709473.73312 1.986525.89867v-6.19607l-.638526-.18919q-1.773682-.54393-2.743295-1.82098-.945964-1.27705-.945964-3.00344 0-1.89192 1.158805-3.12168 1.182455-1.22975 3.16898-1.41894v-3.12168h1.418946v3.09803q1.9628749.18919 3.1453299 1.41894 1.182455 1.22976 1.2061041 3.16898h-2.128419q0-1.1115-.5912275-1.79733-.5675783-.70947-1.6317875-.87501v5.86497l.9459637.30744q1.7263842.54393 2.6723482 1.84463.945964 1.3007.945964 3.05073 0 1.93923-1.2534023 3.21628-1.2297532 1.27705-3.3108736 1.489894v3.098032zm-2.199367-16.412476q0 1.06421.567579 1.84463.567578.78042 1.631788 1.11151v-5.41564q-1.040561.16554-1.631788.80406-.567579.63853-.567579 1.65544zm3.618313 11.39887q1.1588056-.1892 1.7973312-.89867.6385257-.70947.6385257-1.84463 0-1.04056-.5675784-1.79733-.5439293-.78042-1.5844895-1.11151l-.283789-.0946z" stroke-linecap="round" stroke-linejoin="round" stroke-width="2.0111" transform="matrix(-1.0225486 0 0 -.97794862 24.554623 -1.612364)"/><path d="m-59.312695-104.31899q-.273111.60084-.710088 1.11065-.436977.4916-1.001405.87395-.546222.36415-1.219894.58264-.673673.20028-1.438383.20028-.782917 0-1.584041-.21849-.801125-.2367-1.547627-.54622-.910369-.36415-1.747908-.69188-.819332-.32774-1.602249-.32774-.910368 0-1.511212.45519-.600843.43698-1.074235 1.20169l-1.857152-1.32914q.273111-.60085.710088-1.09244.436977-.50981.983198-.87396.564429-.38235 1.238101-.58263.673673-.21849 1.438383-.21849.892161 0 1.80253.29131.910368.29132 1.747908.63726.782917.32774 1.529419.60085.746502.2549 1.401968.2549.892161 0 1.511212-.43698.61905-.45518 1.074235-1.21989z" fill="#83d200" stroke-width="1.32731" transform="scale(-1)"/><path d="m100.26473 100.06705v-3.596477l-7.441251 7.192957 7.441251 7.19295v-3.59647l-3.720626-3.59648z"/></g></svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

1
zumepro_light_full.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.1 KiB