changed structure for docker usage

This commit is contained in:
Marcel Gansfusz
2025-10-24 21:02:42 +02:00
parent b9eb5e8bd4
commit 98742107b2
18 changed files with 99 additions and 14 deletions

407
app/static/app.js Normal file
View File

@@ -0,0 +1,407 @@
class PDFView {
constructor(nameRoute) {
this.loadingTask = pdfjsLib.getDocument(nameRoute);
this.pdfDoc = null;
this.canvas = document.querySelector("#cnv");
this.ctx = this.canvas.getContext("2d");
this.scale = 1.5;
this.numPage = 1;
this.maxheight =
window.innerHeight -
document.getElementById("buttonsdiv").getBoundingClientRect().height;
this.maxwidth = document
.getElementById("cnvdiv")
.getBoundingClientRect().width;
this.rendering = false;
this.loadingTask.promise.then((pdfDoc_) => {
this.pdfDoc = pdfDoc_;
document.querySelector("#npages").innerHTML = this.pdfDoc.numPages;
this.GeneratePDF();
});
}
GeneratePDF() {
this.rendering = true;
this.pdfDoc.getPage(this.numPage).then((page) => {
let unscaled = page.getViewport({ scale: 1.0 });
this.scale = Math.min(
this.maxheight / unscaled.height,
this.maxwidth / unscaled.width,
);
});
this.pdfDoc.getPage(this.numPage).then((page) => {
let viewport = page.getViewport({ scale: this.scale });
this.canvas.height = viewport.height;
this.canvas.width = viewport.width;
let renderContext = {
canvasContext: this.ctx,
viewport: viewport,
};
doc.cnv.width = this.canvas.width;
doc.cnv.height = this.canvas.height;
document.getElementById("rightdiv").style.width =
((doc.cnv.width / screen.width) * 100).toString() + "vw";
document.getElementById("controldiv").style.width =
((1 - doc.cnv.width / screen.width) * 100).toString() + "vw";
doc.pagescales[this.numPage] = {
scale: this.scale,
width: doc.cnv.width,
height: doc.cnv.height,
};
var renderTask = page.render(renderContext);
renderTask.promise.then(() => {
doc.drawRects();
this.rendering = false;
});
});
document.querySelector("#npage").innerHTML = this.numPage;
}
WaitToRender() {
if (this.rendering) {
window.setTimeout(this.WaitToRender.bind(this), 100);
} else {
this.GeneratePDF();
}
}
PrevPage() {
if (this.numPage === 1) {
return;
}
this.numPage--;
this.WaitToRender();
}
NextPage() {
if (this.numPage >= this.pdfDoc.numPages) {
return;
}
this.numPage++;
this.WaitToRender();
}
RenderPage() {
this.WaitToRender();
}
}
class Rectangle {
constructor(canvas, sx, sy, ex, ey, color, alpha = 1) {
this.x = sx < ex ? sx : ex;
this.y = sy < ey ? sy : ey;
this.width = Math.abs(ex - sx);
this.height = Math.abs(ey - sy);
this.color = color;
this.context = canvas.getContext("2d");
this.alpha = alpha;
}
draw() {
this.context.globalAlpha = this.alpha;
this.context.beginPath();
this.context.rect(this.x, this.y, this.width, this.height);
this.context.fillStyle = this.color;
this.context.strokeStyle = "black";
this.context.lineWidth = 1;
this.context.fill();
this.context.stroke();
}
makeTuple() {
return [this.x, this.y, this.width, this.height];
}
}
class PDFDocument {
constructor(filename, fileID, filetype) {
if (filetype === "pdf") {
this.pdf = new PDFView(filename);
} else {
this.pdf = new PDFView("/files/unsupported");
}
this.filetype = filetype;
this.fname = filename;
this.fID = fileID;
this.rects = [];
this.cnv = document.querySelector("#drw_cnv");
this.ctx = this.cnv.getContext("2d");
this.temprect = new Rectangle(this.cnv, 0, 0, 0, 0, "white", 0);
this.pagescales = [];
this.startX = 0;
this.startY = 0;
}
drawAll() {
//context = cnv.getContext("2d");
this.ctx.clearRect(0, 0, this.cnv.width, this.cnv.height);
//pdf.RenderPage();
this.drawRects();
}
drawRects() {
if (!(this.pdf.numPage in this.rects)) {
this.rects[this.pdf.numPage] = [];
}
this.temprect.draw();
for (var i = 0; i < this.rects[this.pdf.numPage].length; i++) {
var shape = this.rects[this.pdf.numPage][i];
shape.draw();
}
}
addRect(endpos) {
var re = new Rectangle(
this.cnv,
this.startX,
this.startY,
endpos.x,
endpos.y,
"black",
);
this.rects[this.pdf.numPage].push(re);
this.drawAll();
}
clearCnv() {
this.rects[this.pdf.numPage] = [];
//context = cnv.getContext("2d");
this.ctx.clearRect(0, 0, this.cnv.width, this.cnv.height);
//pdf.RenderPage();
this.temprect = new Rectangle(this.cnv, 0, 0, 0, 0, "black", 0);
}
clearAll() {
this.rects = [];
this.clearCnv();
}
get paramRects() {
let prects = [];
for (var k = 1; k < this.rects.length; k++) {
prects[k - 1] = [];
//console.log(this.rects[k]);
if (this.rects[k] === undefined) {
continue;
}
//console.log(this.rects[k].length);
//console.log(0 < this.rects[k].length);
let len = this.rects[k].length;
for (var i = 0; i < len; i++) {
//console.log(this.rects[k][i]);
prects[k - 1].push(this.rects[k][i].makeTuple());
//console.log(prects[k][i]);
}
}
return prects;
}
}
var mouseIsDown = false;
var modal;
var close_loading;
var upload_status;
//var startX = 0;
//var startY = 0;
//var pdf;
//var cnv = document.querySelector("#drw_cnv");
//var ctx = cnv.getContext("2d");
//var rects = {};
//var temprect = new Rectangle(cnv, 0, 0, 0, 0, "white", 0);
//var pagescales = {};
function getMousePos(cnv, eve) {
var rect = cnv.getBoundingClientRect();
return {
x: eve.clientX - rect.left,
y: eve.clientY - rect.top,
};
}
function mouseDown(eve) {
//console.log(eve);
if (eve.buttons != 1) {
return;
}
if (mouseIsDown) {
return;
}
mouseIsDown = true;
var pos = getMousePos(cnv, eve);
doc.startX = pos.x;
doc.startY = pos.y;
}
function mouseUp(eve) {
//console.log(eve);
if (eve.buttons != 0) {
return;
}
if (!mouseIsDown) {
return;
}
mouseIsDown = false;
doc.addRect(getMousePos(cnv, eve));
doc.temprect = new Rectangle(doc.cnv, 0, 0, 0, 0, "black", 0);
}
//var mousexy = 0;
function mouSexy(eve) {
if (mouseIsDown) {
var pos = getMousePos(doc.cnv, eve);
doc.temprect = new Rectangle(
doc.cnv,
doc.startX,
doc.startY,
pos.x,
pos.y,
"black",
0.5,
);
doc.drawAll();
}
}
function scrollPage(eve) {
console.log(eve);
if (eve.ctrlKey) {
return;
}
if (eve.deltaY > 0) {
doc.pdf.NextPage();
} else {
doc.pdf.PrevPage();
}
}
const initDraw = () => {
var cnv = document.querySelector("#drw_cnv");
cnv.addEventListener("mousedown", mouseDown, false);
cnv.addEventListener("mouseup", mouseUp, false);
cnv.addEventListener("mousemove", mouSexy, false);
cnv.addEventListener("wheel", scrollPage, false);
};
function submitPdf(eve) {
eve.preventDefault();
var formdata = new FormData(eve.target);
console.log(doc.paramRects);
formdata.append("rects", JSON.stringify(doc.paramRects));
formdata.append("pagescales", JSON.stringify(doc.pagescales.slice(1)));
formdata.append("fileId", doc.fID);
//formdata.append("filename", doc.filename);
formdata.append("ftype", doc.filetype);
if (!formdata.has("ocr")) {
formdata.append("ocr", "False");
}
console.log(formdata);
submitForm(formdata);
}
async function submitForm(formData) {
try {
const updateEventSource = new EventSource(
"http://127.0.0.1:8000/get_censor_status/" + doc.fID,
);
modal.style.display = "flex";
// console.log("http://127.0.0.1:8000/get_censor_status/" + doc.fID);
updateEventSource.addEventListener("censorUpdate", function(eve) {
console.log(eve.data);
var data = JSON.parse(eve.data);
upload_status.innerText =
"Censoring Page " + data.page + "/" + data.pages;
});
const response = await fetch("http://127.0.0.1:8000/submit", {
method: "POST",
body: formData,
});
updateEventSource.close();
modal.style.display = "none";
//let responseJSON=await response.json();
if (response.ok) {
console.log("Submit OK");
doc = new PDFDocument("./files/greeting", "greeting", "pdf");
// console.log(response);
// window.open(response);
// console.log(URL.createObjectURL(response.body));
// window.open(response);
// window.open(response, (target = "_blank"));
// var newWindow = window.open();
// newWindow.document.write(response);
// var blob = response.blob();
const blobURL = URL.createObjectURL(await response.blob());
window.open(blobURL, "_blank");
} else {
console.log("Submit failed");
window.alert("Error: " + (await response.json())["detail"]);
}
} catch (error) {
console.error("Error" + error);
}
}
function uploadPdf(eve) {
eve.preventDefault();
const fileupload = document.querySelector("#filepicker");
const file = fileupload.files;
if (!file) {
alert("Please Choose a file");
return;
}
const form = document.querySelector("#uploadform");
const formData = new FormData(form);
//formData.append("files", file);
uploadFile(formData);
}
async function uploadFile(formData) {
try {
const response = await fetch("http://127.0.0.1:8000/uploadfile", {
method: "POST",
body: formData,
});
let responseJSON = await response.json();
if (response.ok) {
console.log("upload OK " + responseJSON["filename"]);
console.log(response);
delete doc.pdf;
//delete doc;
document.getElementById("name").value = responseJSON.filename;
doc = new PDFDocument(
responseJSON.path,
responseJSON.fid,
responseJSON.filetype,
);
} else {
console.log("upload failed");
window.alert("Error: " + (await response.json())["detail"]);
}
} catch (error) {
console.error("Error: " + error);
}
}
function initUpload() {
document.querySelector("#uploadform").addEventListener("submit", uploadPdf);
document.querySelector("#submitform").addEventListener("submit", submitPdf);
}
function initListeners() {
document.querySelector("#prev").addEventListener("click", function() {
doc.pdf.PrevPage();
});
document.querySelector("#next").addEventListener("click", function() {
doc.pdf.NextPage();
});
document.querySelector("#clr").addEventListener("click", function() {
doc.clearCnv();
});
document.querySelector("#ca").addEventListener("click", function() {
doc.clearAll();
});
}
function initLoading() {
modal = document.querySelector("#loading");
// close_loading = document.querySelector(".close");
upload_status = document.querySelector("#upload_status");
// close_loading.addEventListener("click", function() {
// modal.style.display = "none";
// });
}
const startPdf = () => {
// doc = new PDFDocument(
// "./files/b78c869f-e0bb-11ef-9b58-84144d05d665",
// "b78c869f-e0bb-11ef-9b58-84144d05d665",
// "pdf",
// );
//pdf = new PDFView("./VO_Mathematik_3.pdf");
doc = new PDFDocument("./files/greeting", "greeting", "pdf");
initLoading();
initDraw();
initUpload();
initListeners();
};
window.addEventListener("load", startPdf);

194
app/static/autocomplete.js Normal file
View File

@@ -0,0 +1,194 @@
var url = "http://127.0.0.1:8000/search/";
var lid = null;
var pid = null;
var activeAutocompletion = null;
/*Things I've stolen from https://www.w3schools.com/howto/howto_js_autocomplete.asp*/
function autocomplete(inp, type) {
/*the autocomplete function takes two arguments,
the text field element and an array of possible autocompleted values:*/
var currentFocus;
/*execute a function when someone writes in the text field:*/
inp.addEventListener("focus", (e) => {
e.target.select();
// this.select();
updateAutocomplete();
});
inp.addEventListener("input", updateAutocomplete);
async function updateAutocomplete() {
activeAutocompletion = type;
var a,
b,
i,
apirq,
iname,
val = inp.value;
/*close any already open lists of autocompleted values*/
closeAllLists();
if (!val && type === "lva" && pid === null) {
return false;
}
if (type === "lva" && pid !== null) {
apirq =
url + type + "?searchterm=" + val + "&pid=" + pid + "&searchlim=10";
} else if (type === "prof" && lid !== null) {
apirq =
url + type + "?searchterm=" + val + "&lid=" + lid + "&searchlim=10";
} else if (type === "subcat" && lid !== null && pid !== null) {
apirq =
url +
type +
"?searchterm=" +
val +
"&lid=" +
lid +
"&pid=" +
pid +
"&cat=" +
document.getElementById("submitform").elements["stype"].value +
"&searchlim=10";
} else {
apirq = url + type + "?searchterm=" + val + "&searchlim=10";
}
const response = await fetch(apirq);
currentFocus = -1;
/*create a DIV element that will contain the items (values):*/
a = document.createElement("DIV");
a.setAttribute("id", this.id + "autocomplete-list");
a.setAttribute("class", "autocomplete-items");
/*append the DIV element as a child of the autocomplete container:*/
inp.parentNode.appendChild(a);
/*for each item in the array...*/
//await response;
if (response.ok) {
arr = await response.json();
} else {
console.error("API call failed. Request:\n" + apirq);
return false;
}
for (i = 0; i < arr.length; i++) {
if (type === "lva") {
iname =
arr[i]["lvid"].slice(0, 3) +
"." +
arr[i]["lvid"].slice(3, 6) +
" " +
arr[i]["lvname"];
} else {
iname = arr[i]["name"];
}
console.log(iname);
/*create a DIV element for each matching element:*/
b = document.createElement("DIV");
/*make the matching letters bold:*/
//b.innerHTML = "<strong>" + iname.substr(0, val.length) + "</strong>";
b.innerHTML = iname; //.substr(val.length);
/*insert a input field that will hold the current array item's value:*/
b.innerHTML += "<input type='hidden' value='" + i + "'>";
/*execute a function when someone clicks on the item value (DIV element):*/
b.addEventListener("click", function(e) {
/*insert the value for the autocomplete text field:*/
if (type === "lva") {
const idx = this.getElementsByTagName("input")[0].value;
inp.value =
arr[idx]["lvid"].slice(0, 3) +
"." +
arr[idx]["lvid"].slice(3, 6) +
" " +
arr[idx]["lvname"];
lid = arr[idx]["id"];
} else if (type === "prof") {
const idx = this.getElementsByTagName("input")[0].value;
inp.value = arr[idx]["name"];
pid = arr[idx]["id"];
} else {
inp.value = arr[this.getElementsByTagName("input")[0].value]["name"];
}
/*close the list of autocompleted values,
(or any other open lists of autocompleted values:*/
closeAllLists();
});
a.appendChild(b);
}
/*Add Listener to block the main click listener that destroys the autocompletion*/
inp.addEventListener("click", function(e) {
e.stopImmediatePropagation();
if (activeAutocompletion != type) {
closeAllLists(e.target);
}
});
}
/*execute a function presses a key on the keyboard:*/
inp.addEventListener("keydown", function(e) {
var x = document.getElementById(this.id + "autocomplete-list");
if (x) x = x.getElementsByTagName("div");
if (e.keyCode == 40) {
/*If the arrow DOWN key is pressed,
increase the currentFocus variable:*/
currentFocus++;
/*and and make the current item more visible:*/
addActive(x);
} else if (e.keyCode == 38) {
//up
/*If the arrow UP key is pressed,
decrease the currentFocus variable:*/
currentFocus--;
/*and and make the current item more visible:*/
addActive(x);
} else if (e.keyCode == 13) {
/*If the ENTER key is pressed, prevent the form from being submitted,*/
e.preventDefault();
if (currentFocus > -1) {
/*and simulate a click on the "active" item:*/
if (x) x[currentFocus].click();
}
}
});
function addActive(x) {
/*a function to classify an item as "active":*/
if (!x) return false;
/*start by removing the "active" class on all items:*/
removeActive(x);
if (currentFocus >= x.length) currentFocus = 0;
if (currentFocus < 0) currentFocus = x.length - 1;
/*add class "autocomplete-active":*/
x[currentFocus].classList.add("autocomplete-active");
}
function removeActive(x) {
/*a function to remove the "active" class from all autocomplete items:*/
for (var i = 0; i < x.length; i++) {
x[i].classList.remove("autocomplete-active");
}
}
function closeAllLists(elmnt) {
/*close all autocomplete lists in the document,
except the one passed as an argument:*/
var x = document.getElementsByClassName("autocomplete-items");
for (var i = 0; i < x.length; i++) {
if (elmnt != x[i] && elmnt != inp) {
x[i].parentNode.removeChild(x[i]);
}
}
}
/*execute a function when someone clicks in the document:*/
document.addEventListener("click", function(e) {
closeAllLists(e.target);
});
}
function enter_current_semeseter() {
var semField = document.getElementById("sem");
var today = new Date();
var year = today.getFullYear();
var month = today.getMonth();
if (month < 9 && month > 1) {
semField.value = String(year) + "S";
} else {
semField.value = String(year) + "W";
}
}
function init() {
autocomplete(document.getElementById("lva"), "lva");
autocomplete(document.getElementById("prof"), "prof");
autocomplete(document.getElementById("subcat"), "subcat");
enter_current_semeseter();
}
window.addEventListener("load", init);

42
app/static/dynhide.js Normal file
View File

@@ -0,0 +1,42 @@
var radiobuttons;
var datediv;
var subcatdiv;
var rdbarr;
var subcatcategories = [1, 2, 3];
var datecategorires = [0, 1];
function changevis() {
for (let i = 0; i < rdbarr.length; i++) {
if (rdbarr[i].checked) {
if (subcatcategories.includes(i)) {
subcatdiv.style.display = "block";
} else {
subcatdiv.style.display = "none";
}
if (datecategorires.includes(i)) {
datediv.style.display = "block";
} else {
datediv.style.display = "none";
}
return;
}
}
}
function starthide() {
radiobuttons = document.getElementsByName("stype");
datediv = document.getElementById("datediv");
subcatdiv = document.getElementById("subcatdiv");
rdbarr = [
document.getElementById("pruefung"),
document.getElementById("klausur"),
document.getElementById("uebung"),
document.getElementById("labor"),
document.getElementById("unterlagen"),
document.getElementById("zusammenfassungen"),
document.getElementById("multimedia"),
];
changevis();
radiobuttons.forEach((rdb) => {
rdb.addEventListener("change", changevis);
});
}
window.addEventListener("load", starthide);

10
app/static/filedrop.js Normal file
View File

@@ -0,0 +1,10 @@
var fileinput;
function dropHandler(eve) {
eve.preventDefault();
fileinput.files = eve.dataTransfer.files;
}
function init() {
fileinput = document.getElementById("filepicker");
document.getElementById("filepicker").addEventListener("drop", dropHandler);
}
window.addEventListener("load", init);

321
app/static/style.css Normal file
View File

@@ -0,0 +1,321 @@
html,
body {
height: 100vh;
width: 100vw;
}
body {
overflow: hidden;
background-color: #2f3957;
font-family: Arial, Helvetica, sans-serif;
}
.right {
height: 100%;
margin: 0;
width: 75vw;
float: right;
/* background-color: navy; */
}
.left {
/* background-color: blueviolet; */
height: 100%;
float: left;
}
span {
color: lightgray;
font-family: Arial, Helvetica, sans-serif;
}
.fullsize {
height: 100%;
margin: 0;
}
.buttons {
display: flex;
justify-content: space-between;
}
button {
background-color: #0872a9;
/* border-radius: 20px; */
border-top-left-radius: 10px;
border-top-right-radius: 10px;
border-bottom-right-radius: 0px;
border-bottom-left-radius: 0px;
}
.main {
height: 100vh;
width: 100vw;
position: absolute;
top: 0;
left: 0;
}
#cnvdiv {
position: relative;
height: 100%;
width: 100%;
/*display: flex;*/
/*text-align: center;*/
/*justify-content: center;*/
}
.stack {
position: relative;
height: 100%;
/*text-align: center;*/
/*float: right;*/
/*margin-right: 0;*/
/*margin-left: auto;*/
/*display: block;*/
}
.stack>canvas {
position: absolute;
/*display: block;*/
/*display: inline;*/
left: 0;
top: 0;
}
#submitdiv {
/*position: relative;*/
width: 500px;
padding: 10px;
}
label {
/* color: white; */
background-color: grey;
border-radius: 5px;
padding: 5px;
/* margin: 10px; */
margin-top: 10px;
}
/*Things I've stolen from https://www.w3schools.com/howto/howto_js_autocomplete.asp*/
.autocomplete {
/*the container must be positioned relative:*/
position: relative;
display: inline-block;
/* width: 400px; */
width: 100%;
padding: none;
margin-bottom: 10px;
}
#name {
margin-bottom: 10px;
}
input {
border: 1px solid #818181;
background-color: #a1a1a1;
padding: 10px;
/* height: 50px; */
font-size: 12pt;
border-radius: 20px;
margin-top: 10px;
/* margin-bottom: 10px; */
}
input[type="text"] {
background-color: #b1b1b1;
/* width: 100%; */
width: 478px;
border-radius: 20px;
}
/* input[type="text"]:focus { */
/* border-bottom-right-radius: 0px; */
/* border-bottom-left-radius: 0px; */
/* outline: none; */
/* } */
div>input[type="text"]:focus {
border-bottom-right-radius: 0px;
border-bottom-left-radius: 0px;
outline: none;
}
button[type="submit"] {
background-color: #0872a9;
/* color: #fff; */
border-radius: 20px;
padding: 5px;
}
.autocomplete-items {
position: absolute;
/* border: 1px solid #d4d4d4; */
border: 1px solid #818181;
background-color: #b1b1b1;
/* background-color: #b1b1b1; */
border-bottom: none;
border-top: none;
z-index: 99;
/*position the autocomplete items to be the same width as the container:*/
top: 100%;
left: 0;
right: 0;
border-bottom-left-radius: 20px;
border-bottom-right-radius: 20px;
/* padding: 20px; */
/* padding-top: 10px; */
padding-bottom: 20px;
}
.autocomplete-items div {
padding: 10px;
cursor: pointer;
/* border-radius: 20px; */
background-color: #b1b1b1;
border-bottom: 1px solid #d4d4d4;
}
.autocomplete-items div:hover {
/*when hovering an item:*/
background-color: #0872a9;
}
.autocomplete-active {
/*when navigating through the items using the arrow keys:*/
background-color: #0872a9 !important;
color: #ffffff;
}
/* filedrop */
input[type="file"] {
/* flex: 1; */
display: flex;
background-color: #b1b1b1;
border-radius: 20px;
padding: 10px;
width: 100%;
margin-right: 10px;
margin-top: 10px;
margin-left: 10px;
/* margin-bottom: auto; */
}
input[type="file"]::file-selector-button {
background-color: #d1d1d1;
border-radius: 20px;
border-color: #a1a1a1;
}
#fileupload {
/* display: inline-flex; */
width: 500px;
border-radius: 20px;
background-color: #4f5977;
margin: 10px;
}
.fileupload {
margin-left: auto;
margin-right: 10px;
margin-top: 10px;
margin-bottom: 10px;
width: 25%;
/* align-self: right; */
/* float: right; */
}
#uploadform {
display: flex;
flex-direction: row;
flex-wrap: wrap;
}
.filetop {
display: flex;
width: 100%;
/* background-color: purple; */
}
/* The Modal (background) */
.modal {
display: none;
/* Hidden by default */
position: fixed;
/* Stay in place */
z-index: 1;
/* Sit on top */
left: 0;
top: 0;
width: 100%;
/* Full width */
height: 100%;
/* Full height */
overflow: auto;
/* Enable scroll if needed */
background-color: #4f5977;
/* Fallback color */
background-color: rgba(0, 0, 0, 0.4);
/* Black w/ opacity */
justify-content: center;
}
/* Modal Content/Box */
.loading-content {
background-color: #4f5977;
margin: auto;
/* 15% from the top and centered */
padding: 20px;
/* border: 1px solid #888; */
/* width: 80%; */
border-radius: 15px;
display: flex;
flex-direction: column;
/* Could be more or less, depending on screen size */
align-items: center;
text-align: center;
}
/* The Close Button */
.close {
color: #aaa;
float: right;
font-size: 28px;
font-weight: bold;
}
.close:hover,
.close:focus {
color: black;
text-decoration: none;
cursor: pointer;
}
.upload_status_text {
color: #ffffff;
font-size: 16pt;
}
.loader {
margin: auto;
border: 16px solid #f3f3f3;
/* Light grey */
border-top: 16px solid #3498db;
/* Blue */
border-radius: 50%;
width: 120px;
height: 120px;
animation: spin 2s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}