Files
gitea-uploader/index.html
T
2026-09-26 00:13:27 +02:00

555 lines
23 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Gitea Upload</title>
<style>
:root{
--bg:#141517; --panel:#1c1d20; --panel-alt:#202225; --text:#e6e7ea; --muted:#8b9099;
--accent:#f07d1e; --accent-dark:#c9650f; --border:#2c2e33;
--ok:#5fd98a; --err:#ff8a80; --header-bg:#0d0e0f;
}
*{box-sizing:border-box;}
body{margin:0;font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;background:var(--bg);color:var(--text);}
header{background:var(--header-bg);border-bottom:2px solid var(--accent);padding:16px 24px;
display:flex;align-items:center;justify-content:space-between;}
header .title{font-weight:800;font-size:1.3rem;letter-spacing:.5px;}
header .title .accent{color:var(--accent);}
header .subtitle{color:var(--muted);font-size:.8rem;margin-left:10px;font-weight:600;letter-spacing:.5px;}
header .icons{display:flex;gap:8px;}
header .icons button{background:var(--panel);border:1px solid var(--border);color:var(--text);
width:32px;height:32px;border-radius:6px;cursor:pointer;font-size:1rem;
display:flex;align-items:center;justify-content:center;padding:0;line-height:1;}
header .icons button:hover{border-color:var(--accent);color:var(--accent);}
button:focus{outline:none;}
button:focus-visible{outline:2px solid var(--accent);outline-offset:2px;}
main{max-width:760px;margin:24px auto 40px;padding:0 16px;}
.card{background:var(--panel);border:1px solid var(--border);border-radius:10px;padding:20px 22px;margin-bottom:18px;}
.card h2{margin:0 0 4px;font-size:1.02rem;}
.card .step-num{display:inline-flex;align-items:center;justify-content:center;width:22px;height:22px;
border-radius:50%;background:var(--accent);color:#111;font-size:.8rem;font-weight:700;margin-right:8px;}
label{display:block;font-size:.85rem;color:var(--muted);margin:12px 0 4px;}
input[type=text],input[type=password],input[type=url],select{
width:100%;padding:9px 10px;border:1px solid var(--border);border-radius:6px;font-size:.93rem;
background:var(--panel-alt);color:var(--text);
}
input::placeholder{color:#5b5f66;}
input[type=checkbox]{margin-right:6px;accent-color:var(--accent);}
.checkline{display:flex;align-items:center;font-size:.85rem;color:var(--muted);margin-top:10px;}
.row{display:flex;gap:12px;flex-wrap:wrap;}
.row > *{flex:1;min-width:150px;}
button{background:var(--accent);color:#111;border:none;border-radius:6px;padding:9px 15px;
font-size:.9rem;font-weight:600;cursor:pointer;margin-top:14px;}
button:hover{background:var(--accent-dark);color:#fff;}
button.secondary{background:var(--panel-alt);color:var(--text);border:1px solid var(--border);}
button.secondary:hover{border-color:var(--accent);color:var(--accent);background:var(--panel-alt);}
button.small{padding:6px 12px;font-size:.82rem;margin-top:0;}
button:disabled{background:#3a3c40;color:#787c82;cursor:not-allowed;}
.hint{font-size:.78rem;color:var(--muted);margin-top:6px;}
.status{margin-top:12px;font-size:.87rem;}
.status.ok{color:var(--ok);}
.status.err{color:var(--err);}
#log{margin-top:14px;background:#0d0e0f;color:#c7cad0;border-radius:8px;padding:12px;
font-family:ui-monospace,Consolas,monospace;font-size:.8rem;max-height:200px;overflow-y:auto;
white-space:pre-wrap;display:none;border:1px solid var(--border);}
#log .l-ok{color:var(--ok);}
#log .l-err{color:var(--err);}
.progress-wrap{margin-top:10px;height:7px;border-radius:4px;background:#2c2e33;overflow:hidden;display:none;}
.progress-bar{height:100%;background:var(--accent);width:0%;transition:width .2s;}
fieldset{border:1px solid var(--border);border-radius:8px;padding:14px;margin-top:10px;}
fieldset legend{font-size:.8rem;color:var(--muted);padding:0 6px;}
.disabled-section{opacity:.45;pointer-events:none;}
details{margin-top:10px;font-size:.85rem;color:var(--muted);}
details summary{cursor:pointer;color:var(--text);}
#fileList{max-height:150px;overflow-y:auto;margin-top:8px;font-family:ui-monospace,Consolas,monospace;
font-size:.78rem;background:var(--panel-alt);border-radius:6px;padding:8px;border:1px solid var(--border);}
footer{display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px;color:var(--muted);
font-size:.8rem;padding:18px 24px;border-top:1px solid var(--border);}
footer a{color:var(--muted);text-decoration:none;margin-right:16px;}
footer a:hover{color:var(--accent);}
.overlay{position:fixed;inset:0;background:rgba(0,0,0,.6);display:none;align-items:center;
justify-content:center;z-index:50;}
.overlay.open{display:flex;}
.modal{background:var(--panel);border:1px solid var(--border);border-radius:10px;padding:22px 24px;
max-width:480px;width:90%;max-height:80vh;overflow-y:auto;}
.modal h3{margin-top:0;}
.modal .close{float:right;background:none;border:none;color:var(--muted);font-size:1.2rem;
cursor:pointer;margin:0;padding:0;}
.modal ol,.modal ul{padding-left:20px;font-size:.9rem;color:var(--text);}
.modal li{margin-bottom:8px;}
</style>
</head>
<body>
<header>
<div>
<span class="title">GITEA <span class="accent">UPLOAD</span></span>
<span class="subtitle">ZIP HOCHLADEN</span>
</div>
<div class="icons">
<button id="btnHelp" title="Anleitung">?</button>
<button id="btnSettings" title="Einstellungen">⚙</button>
</div>
</header>
<main>
<div class="card">
<h2><span class="step-num">1</span>Gitea-Zugang</h2>
<label for="serverUrl">Gitea-Server</label>
<input type="url" id="serverUrl" placeholder="https://git.beispiel.de">
<div class="row">
<div>
<label for="username">Benutzername</label>
<input type="text" id="username" autocomplete="username">
</div>
<div>
<label for="password">Passwort oder Zugangstoken</label>
<input type="password" id="password" autocomplete="current-password">
</div>
</div>
<p class="hint">Ein Zugangstoken (Einstellungen → Anwendungen → Neues Token in Gitea) ist sicherer als das echte Passwort.</p>
<button id="btnLoadRepos">Verbinden &amp; Repos laden</button>
<div id="authStatus" class="status"></div>
</div>
<div class="card disabled-section" id="repoCard">
<h2><span class="step-num">2</span>Ziel wählen</h2>
<label for="ownerSelect">Konto / Organisation</label>
<select id="ownerSelect"><option value="">– bitte zuerst verbinden –</option></select>
<label for="repoSelect">Vorhandenes Repo</label>
<select id="repoSelect"><option value="">– bitte zuerst verbinden –</option></select>
<fieldset>
<legend>Oder neues Repo anlegen</legend>
<div class="row">
<input type="text" id="newRepoName" placeholder="repo-name">
<label class="checkline" style="flex:0 0 auto;">
<input type="checkbox" id="newRepoPrivate" checked> Privat
</label>
</div>
<button class="secondary" id="btnCreateRepo">Repo erstellen &amp; auswählen</button>
</fieldset>
<div id="repoStatus" class="status"></div>
</div>
<div class="card disabled-section" id="zipCard">
<h2><span class="step-num">3</span>ZIP-Datei wählen</h2>
<input type="file" id="zipFile" accept=".zip">
<label class="checkline"><input type="checkbox" id="stripRoot" checked> Obersten Ordner aus der ZIP entfernen</label>
<label class="checkline"><input type="checkbox" id="removeMissing"> Dateien im Repo löschen, die nicht mehr in der ZIP enthalten sind</label>
<div id="zipStatus" class="status"></div>
<details id="fileListDetails" style="display:none;">
<summary>Enthaltene Dateien anzeigen</summary>
<div id="fileList"></div>
</details>
</div>
<div class="card disabled-section" id="uploadCard">
<h2><span class="step-num">4</span>Hochladen</h2>
<label for="commitMsg">Commit-Nachricht</label>
<input type="text" id="commitMsg" value="Upload per ZIP-Tool">
<button id="btnUpload">Jetzt hochladen</button>
<div class="progress-wrap" id="progressWrap"><div class="progress-bar" id="progressBar"></div></div>
<div id="uploadStatus" class="status"></div>
<div id="log"></div>
</div>
</main>
<footer>
<div>
<a href="/impressum">Impressum</a>
<a href="/datenschutz">Datenschutz</a>
<a href="./LICENSE">Lizenzen &amp; Quellcode</a>
<a href="#" id="footerAnleitung">Anleitung</a>
</div>
<div>Alle Daten bleiben in deinem Browser · MIT-Lizenz</div>
</footer>
<!-- Anleitung -->
<div class="overlay" id="helpOverlay">
<div class="modal">
<button class="close" data-close="helpOverlay">×</button>
<h3>Anleitung</h3>
<ol>
<li>Gitea-Server-URL, Benutzername und Passwort oder Zugangstoken eingeben, dann "Verbinden &amp; Repos laden".</li>
<li>Konto/Organisation wählen, dann ein vorhandenes Repo auswählen <strong>oder</strong> einen neuen Namen eingeben und "Repo erstellen".</li>
<li>ZIP-Datei auswählen. Enthält sie einen einzelnen umschließenden Ordner, wird der auf Wunsch automatisch entfernt.</li>
<li>Commit-Nachricht anpassen (optional) und "Jetzt hochladen" klicken.</li>
</ol>
<p>Dateien mit gleichem Pfad wie im Repo werden aktualisiert statt doppelt angelegt. Über
"Zugangsdaten in diesem Browser speichern" (Einstellungen, Zahnrad oben rechts) müssen Server,
Benutzername und Passwort nicht jedes Mal neu eingegeben werden.</p>
</div>
</div>
<!-- Einstellungen -->
<div class="overlay" id="settingsOverlay">
<div class="modal">
<button class="close" data-close="settingsOverlay">×</button>
<h3>Einstellungen</h3>
<label class="checkline"><input type="checkbox" id="rememberCreds"> Zugangsdaten in diesem Browser speichern</label>
<p class="hint">Werden nur lokal in diesem Browser gespeichert, nirgendwo sonst übertragen. Auf einem
gemeinsam genutzten Rechner besser deaktiviert lassen.</p>
<button class="secondary small" id="btnClearCreds">Gespeicherte Zugangsdaten löschen</button>
<hr style="border-color:var(--border);margin:18px 0;">
<p class="hint" style="margin-top:0;">Zugangsdaten als Datei sichern oder auf einem anderen Gerät wieder einlesen:</p>
<button class="secondary small" id="btnExport">Export</button>
<button class="secondary small" id="btnImportTrigger">Import</button>
<input type="file" id="importFile" accept=".json" style="display:none;">
</div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
<script>
(function(){
"use strict";
var STORAGE_KEY = "giteaUploaderCreds";
var state = {
server: "", authHeader: "", repos: [], owners: [], selectedOwner: "",
repo: null, // {owner, name, defaultBranch}
zipFiles: null
};
function el(id){ return document.getElementById(id); }
function setStatus(id, msg, cls){
var n = el(id);
n.textContent = msg;
n.className = "status" + (cls ? " " + cls : "");
}
function enable(id){ el(id).classList.remove("disabled-section"); }
function disable(id){ el(id).classList.add("disabled-section"); }
function b64utf8(str){ return btoa(unescape(encodeURIComponent(str))); }
function apiUrl(path){ return state.server.replace(/\/+$/,"") + path; }
function logLine(msg, isErr){
var log = el("log");
log.style.display = "block";
var line = document.createElement("div");
line.className = isErr ? "l-err" : "l-ok";
line.textContent = msg;
log.appendChild(line);
log.scrollTop = log.scrollHeight;
}
function openOverlay(id){ el(id).classList.add("open"); }
function closeOverlay(id){ el(id).classList.remove("open"); }
document.querySelectorAll("[data-close]").forEach(function(btn){
btn.addEventListener("click", function(){ closeOverlay(btn.getAttribute("data-close")); });
});
document.querySelectorAll(".overlay").forEach(function(ov){
ov.addEventListener("click", function(e){ if(e.target === ov) ov.classList.remove("open"); });
});
el("btnHelp").addEventListener("click", function(){ openOverlay("helpOverlay"); });
el("footerAnleitung").addEventListener("click", function(e){ e.preventDefault(); openOverlay("helpOverlay"); });
el("btnSettings").addEventListener("click", function(){ openOverlay("settingsOverlay"); });
async function apiFetch(path, opts){
opts = opts || {};
opts.headers = Object.assign({
"Authorization": state.authHeader,
"Content-Type": "application/json"
}, opts.headers || {});
var res = await fetch(apiUrl(path), opts);
if(!res.ok){
var msg = "HTTP " + res.status;
try{ var body = await res.json(); if(body && body.message) msg += ": " + body.message; }catch(e){}
throw new Error(msg);
}
if(res.status === 204) return null;
return res.json();
}
// ---- Zugangsdaten merken ----
function loadStoredCreds(){
try{
var raw = localStorage.getItem(STORAGE_KEY);
if(!raw) return;
var data = JSON.parse(raw);
el("serverUrl").value = data.server || "";
el("username").value = data.username || "";
el("password").value = data.password || "";
el("rememberCreds").checked = true;
}catch(e){}
}
function saveCredsIfWanted(){
if(el("rememberCreds").checked){
localStorage.setItem(STORAGE_KEY, JSON.stringify({
server: el("serverUrl").value.trim(),
username: el("username").value.trim(),
password: el("password").value
}));
}else{
localStorage.removeItem(STORAGE_KEY);
}
}
el("btnClearCreds").addEventListener("click", function(){
localStorage.removeItem(STORAGE_KEY);
el("rememberCreds").checked = false;
el("password").value = "";
setStatus("authStatus", "Gespeicherte Zugangsdaten gelöscht.", "ok");
});
loadStoredCreds();
// ---- Export / Import ----
el("btnExport").addEventListener("click", function(){
var data = {
server: el("serverUrl").value.trim(),
username: el("username").value.trim(),
password: el("password").value
};
var blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
var a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = "gitea-uploader-zugangsdaten.json";
a.click();
URL.revokeObjectURL(a.href);
});
el("btnImportTrigger").addEventListener("click", function(){ el("importFile").click(); });
el("importFile").addEventListener("change", function(){
var file = this.files[0];
if(!file) return;
var reader = new FileReader();
reader.onload = function(){
try{
var data = JSON.parse(reader.result);
el("serverUrl").value = data.server || "";
el("username").value = data.username || "";
el("password").value = data.password || "";
closeOverlay("settingsOverlay");
setStatus("authStatus", "Zugangsdaten importiert. Jetzt verbinden.", "ok");
}catch(e){
alert("Datei konnte nicht gelesen werden: " + e.message);
}
};
reader.readAsText(file);
});
// ---- Schritt 1: Verbinden & Repos laden ----
el("btnLoadRepos").addEventListener("click", async function(){
state.server = el("serverUrl").value.trim();
var user = el("username").value.trim();
var pass = el("password").value;
if(!state.server || !user || !pass){
setStatus("authStatus", "Bitte Server, Benutzername und Passwort/Token ausfüllen.", "err");
return;
}
state.authHeader = "Basic " + b64utf8(user + ":" + pass);
setStatus("authStatus", "Verbinde...", "");
try{
var all = [];
for(var page = 1; page <= 5; page++){
var batch = await apiFetch("/api/v1/user/repos?limit=50&page=" + page);
all = all.concat(batch);
if(batch.length < 50) break;
}
state.repos = all;
var orgs = [];
try{ orgs = await apiFetch("/api/v1/user/orgs?limit=50"); }catch(e){}
state.owners = [user].concat(orgs.map(function(o){ return o.username || o.name; }));
var ownerSel = el("ownerSelect");
ownerSel.innerHTML = state.owners.map(function(o){
return "<option value='" + o + "'>" + o + (o === user ? " (persönlich)" : "") + "</option>";
}).join("");
state.selectedOwner = state.owners[0];
refreshRepoOptions();
saveCredsIfWanted();
enable("repoCard");
setStatus("authStatus", "Verbunden. " + all.length + " Repo(s) gefunden.", "ok");
}catch(e){
setStatus("authStatus", "Login fehlgeschlagen: " + e.message, "err");
}
});
function refreshRepoOptions(){
var sel = el("repoSelect");
var filtered = state.repos.filter(function(r){ return r.owner.login === state.selectedOwner; });
if(filtered.length === 0){
sel.innerHTML = "<option value=''>– keine Repos in diesem Konto –</option>";
}else{
sel.innerHTML = "<option value=''>– bitte auswählen –</option>" +
filtered.map(function(r){
var idx = state.repos.indexOf(r);
return "<option value='" + idx + "'>" + r.name + (r.private ? " (privat)" : "") + "</option>";
}).join("");
}
state.repo = null;
disable("zipCard");
}
el("ownerSelect").addEventListener("change", function(){
state.selectedOwner = this.value;
refreshRepoOptions();
});
el("repoSelect").addEventListener("change", function(){
var i = this.value;
if(i === ""){ state.repo = null; disable("zipCard"); return; }
var r = state.repos[i];
state.repo = { owner: r.owner.login, name: r.name, defaultBranch: r.default_branch || "main" };
setStatus("repoStatus", "Ausgewählt: " + r.full_name + " (Branch: " + state.repo.defaultBranch + ")", "ok");
enable("zipCard");
});
// ---- Schritt 2: Neues Repo erstellen ----
el("btnCreateRepo").addEventListener("click", async function(){
var name = el("newRepoName").value.trim();
if(!name){ setStatus("repoStatus", "Bitte einen Repo-Namen eingeben.", "err"); return; }
setStatus("repoStatus", "Erstelle Repo...", "");
try{
var priv = el("newRepoPrivate").checked;
var isPersonal = state.selectedOwner === el("username").value.trim();
var path = isPersonal ? "/api/v1/user/repos" : "/api/v1/orgs/" + state.selectedOwner + "/repos";
var r = await apiFetch(path, {
method: "POST",
body: JSON.stringify({ name: name, private: priv, auto_init: true })
});
state.repos.push(r);
refreshRepoOptions();
var sel = el("repoSelect");
sel.value = state.repos.length - 1;
sel.dispatchEvent(new Event("change"));
setStatus("repoStatus", "Repo '" + r.full_name + "' erstellt und ausgewählt.", "ok");
}catch(e){
setStatus("repoStatus", "Erstellen fehlgeschlagen: " + e.message, "err");
}
});
// ---- Schritt 3: ZIP einlesen ----
el("zipFile").addEventListener("change", async function(){
var file = this.files[0];
if(!file) return;
setStatus("zipStatus", "Lese ZIP...", "");
try{
var zip = await JSZip.loadAsync(file);
var entries = [];
var names = Object.keys(zip.files);
for(var i = 0; i < names.length; i++){
var entry = zip.files[names[i]];
if(entry.dir) continue;
var b64 = await entry.async("base64");
entries.push({ path: entry.name, base64: b64 });
}
if(entries.length === 0){
setStatus("zipStatus", "ZIP enthält keine Dateien.", "err");
return;
}
if(el("stripRoot").checked){
var firstSlash = entries[0].path.indexOf("/");
if(firstSlash > 0){
var prefix = entries[0].path.substring(0, firstSlash + 1);
var allShare = entries.every(function(e){ return e.path.indexOf(prefix) === 0; });
if(allShare){
entries = entries.map(function(e){ return { path: e.path.substring(prefix.length), base64: e.base64 }; });
}
}
}
state.zipFiles = entries;
enable("uploadCard");
setStatus("zipStatus", entries.length + " Datei(en) gefunden, bereit zum Hochladen.", "ok");
el("fileList").textContent = entries.map(function(e){ return e.path; }).join("\n");
el("fileListDetails").style.display = "block";
}catch(e){
setStatus("zipStatus", "ZIP konnte nicht gelesen werden: " + e.message, "err");
}
});
// ---- Schritt 4: Hochladen ----
el("btnUpload").addEventListener("click", async function(){
if(!state.repo || !state.zipFiles){
setStatus("uploadStatus", "Bitte erst Repo wählen und ZIP einlesen.", "err");
return;
}
var btn = el("btnUpload");
btn.disabled = true;
el("log").innerHTML = "";
el("log").style.display = "block";
el("progressWrap").style.display = "block";
setStatus("uploadStatus", "Ermittle aktuellen Stand im Repo...", "");
try{
var existing = {};
try{
var tree = await apiFetch(
"/api/v1/repos/" + state.repo.owner + "/" + state.repo.name +
"/git/trees/" + encodeURIComponent(state.repo.defaultBranch) + "?recursive=true"
);
(tree.tree || []).forEach(function(t){ if(t.type === "blob") existing[t.path] = t.sha; });
}catch(e){
logLine("Hinweis: Konnte bestehenden Stand nicht lesen (evtl. leeres Repo) - " + e.message, false);
}
var newPaths = {};
var operations = state.zipFiles.map(function(f){
newPaths[f.path] = true;
var op = { path: f.path, content: f.base64 };
if(existing.hasOwnProperty(f.path)){ op.operation = "update"; op.sha = existing[f.path]; }
else{ op.operation = "create"; }
return op;
});
if(el("removeMissing").checked){
Object.keys(existing).forEach(function(p){
if(!newPaths[p]) operations.push({ path: p, operation: "delete", sha: existing[p] });
});
}
var batches = [];
var current = [];
var currentSize = 0;
var MAX_FILES = 20;
var MAX_BYTES = 3 * 1024 * 1024;
operations.forEach(function(op){
var size = op.content ? op.content.length : 0;
if(current.length >= MAX_FILES || (currentSize + size) > MAX_BYTES){
batches.push(current); current = []; currentSize = 0;
}
current.push(op);
currentSize += size;
});
if(current.length) batches.push(current);
var msg = el("commitMsg").value.trim() || "Upload per ZIP-Tool";
var done = 0, total = operations.length;
for(var b = 0; b < batches.length; b++){
setStatus("uploadStatus", "Lade Batch " + (b+1) + " von " + batches.length + " hoch...", "");
await apiFetch(
"/api/v1/repos/" + state.repo.owner + "/" + state.repo.name + "/contents",
{ method: "POST", body: JSON.stringify({
files: batches[b], message: msg + " (" + (b+1) + "/" + batches.length + ")",
branch: state.repo.defaultBranch
}) }
);
done += batches[b].length;
el("progressBar").style.width = Math.round(done / total * 100) + "%";
logLine("Batch " + (b+1) + "/" + batches.length + " ok (" + batches[b].length + " Datei(en))", false);
}
setStatus("uploadStatus", "Fertig! " + total + " Datei(en) hochgeladen/aktualisiert.", "ok");
}catch(e){
setStatus("uploadStatus", "Fehler: " + e.message, "err");
logLine("Abgebrochen: " + e.message, true);
}finally{
btn.disabled = false;
}
});
})();
</script>
</body>
</html>