Upload via GUI (143 Dateien)

This commit is contained in:
2026-10-04 18:46:00 +00:00
parent 7e1fb616aa
commit 6a0e92eb0f
17 changed files with 2786 additions and 1686 deletions
+436 -66
View File
@@ -541,6 +541,31 @@ function numOrNull(v) { const n = parseInt(v, 10); return Number.isFinite(n) ? n
// 'lila' = Aero in dunklem Lila, 'minecraft' = Block-Optik. Alle außer Klassisch
// bauen auf den dunklen Regeln auf (data-theme="dark") und überschreiben sie über data-design.
const DESIGNS = ['dark', 'light', 'aero', 'lila', 'minecraft'];
// ---------- Bewegte Oberfläche (Optionen → Darstellung → Animationen) ----------
let uiAnimationen = true;
const MIT_STARTANIMATION = new URLSearchParams(location.search).get('startanimation') === '1';
function bewegungErlaubt() {
return uiAnimationen && !window.matchMedia('(prefers-reduced-motion: reduce)').matches;
}
function setzeUiAnimationen(an) {
uiAnimationen = an !== false;
document.documentElement.classList.toggle('ui-animiert', bewegungErlaubt());
if (!bewegungErlaubt()) beendeSkinLive();
}
// Design weich überblenden statt hart umzuschalten (View Transitions von Chromium)
function designUeberblenden(theme) {
const root = document.documentElement;
const vorher = root.getAttribute('data-design') + '/' + root.getAttribute('data-theme');
const t = DESIGNS.includes(theme) ? theme : 'dark';
const nachher = (['aero', 'lila', 'minecraft'].includes(t) ? t : 'klassisch') + '/' + (t === 'light' ? 'light' : 'dark');
// versteckte Fenster (z. B. Hauptfenster im Infobereich) zeichnen nicht –
// dort liefe der Übergang nie an, also gleich umschalten
if (vorher === nachher || !bewegungErlaubt() || !document.startViewTransition || document.hidden) { applyTheme(theme); return; }
document.startViewTransition(() => applyTheme(theme));
setTimeout(() => applyTheme(theme), 600); // Sicherheitsnetz, falls der Übergang hängt
}
function applyTheme(theme) {
const t = DESIGNS.includes(theme) ? theme : 'dark';
const root = document.documentElement;
@@ -561,6 +586,73 @@ let importKind = 'zip';
let importWarteschlange = [];
// Nach einem Import nicht gleich schließen, solange weitere vorbereitete Launcher warten
// ---------- Fortschritt großer Importe ----------
function zuGroesse(b) {
const gb = b / 1073741824;
if (gb >= 1) return gb.toLocaleString('de-DE', { maximumFractionDigits: 1, minimumFractionDigits: 1 }) + ' GB';
return Math.max(0, Math.round(b / 1048576)).toLocaleString('de-DE') + ' MB';
}
function zuDauer(s) {
if (s == null || !isFinite(s)) return '';
if (s < 60) return 'noch wenige Sekunden';
const m = Math.round(s / 60);
if (m < 60) return `noch ca. ${m} Min.`;
return `noch ca. ${Math.floor(m / 60)} Std. ${m % 60} Min.`;
}
function zeigeImportFortschritt(p) {
const balken = el('import-lauf-balken');
const leiste = balken.parentElement;
el('import-lauf').classList.remove('hidden');
if (p.phase === 'zaehlen') {
el('import-lauf-titel').textContent = 'Größe wird ermittelt …';
leiste.classList.add('unbestimmt');
el('import-lauf-zeile').textContent = `${p.dateien.toLocaleString('de-DE')} ${p.dateien === 1 ? 'Datei' : 'Dateien'} · ${zuGroesse(p.bytes)} gefunden`;
el('import-lauf-datei').textContent = '';
return;
}
leiste.classList.remove('unbestimmt');
const titel = p.von > 1 ? `Instanz ${p.nr} von ${p.von}: ${p.instanz}` : `Übernehme ${p.instanz}`;
if (p.phase === 'modpack') {
el('import-lauf-titel').textContent = `${titel} – Modpack-Verknüpfung …`;
return;
}
el('import-lauf-titel').textContent = titel;
const anteil = p.gesamtBytes > 0 ? Math.min(1, p.bytes / p.gesamtBytes) : (p.gesamtDateien ? p.dateien / p.gesamtDateien : 0);
balken.style.width = (anteil * 100).toFixed(1) + '%';
const teile = [`${zuGroesse(p.bytes)} von ${zuGroesse(p.gesamtBytes)} (${Math.round(anteil * 100)} %)`];
if (p.tempo > 0) teile.push(zuGroesse(p.tempo) + '/s');
const rest = zuDauer(p.restSekunden);
if (rest) teile.push(rest);
el('import-lauf-zeile').textContent = teile.join(' · ');
el('import-lauf-datei').textContent = p.datei ? `${p.dateien.toLocaleString('de-DE')} / ${p.gesamtDateien.toLocaleString('de-DE')} Dateien · ${p.datei}` : '';
}
/*
* Führt einen Launcher-Import mit Fortschrittsanzeige aus. Gibt das Ergebnis
* zurück – oder null, wenn er gar nicht erst lief (z. B. zu wenig Platz).
*/
async function importMitFortschritt(aufruf) {
el('import-lauf-titel').textContent = 'Wird vorbereitet …';
el('import-lauf-zeile').textContent = '';
el('import-lauf-datei').textContent = '';
el('import-lauf-balken').style.width = '0';
el('import-lauf-abbrechen').disabled = false;
el('import-lauf').classList.remove('hidden');
let res;
try { res = await aufruf(); }
catch (err) { res = { ok: false, message: err.message }; }
el('import-lauf').classList.add('hidden');
if (!res || !res.ok) {
toast(res && res.message ? res.message : 'Import fehlgeschlagen.', true);
return null;
}
if (res.uebersprungen && res.uebersprungen.length) {
// nach der Zusammenfassung zeigen, sonst überschreibt diese den Hinweis
setTimeout(() => toast(`${res.uebersprungen.length} Datei${res.uebersprungen.length === 1 ? '' : 'en'} konnte${res.uebersprungen.length === 1 ? '' : 'n'} nicht kopiert werden (z. B. gerade geöffnet) und wurde${res.uebersprungen.length === 1 ? '' : 'n'} übersprungen.`, true), 3000);
}
return res;
}
function importAbgeschlossen(kind) {
importWarteschlange = importWarteschlange.filter((k) => k !== kind);
if (importWarteschlange.length) {
@@ -864,12 +956,13 @@ async function runMcImport() {
el('mc-progress').textContent = 'Übernehme …';
el('mc-progress').classList.remove('hidden');
const res = await window.api.mcImport(entries, copy);
const res = await importMitFortschritt(() => window.api.mcImport(entries, copy));
if (!res) { el('mc-do').disabled = false; el('mc-progress').classList.add('hidden'); return; }
await reload();
importAbgeschlossen('mc');
const n = res.imported.length;
const f = res.failed.length;
toast(`${n} Profil${n === 1 ? '' : 'e'} aus dem Minecraft Launcher übernommen${f ? ` · ${f} fehlgeschlagen` : ''}.`, f > 0);
toast(`${n} Profil${n === 1 ? '' : 'e'} aus dem Minecraft Launcher übernommen${f ? ` · ${f} fehlgeschlagen` : ''}${res.abgebrochen ? ' – Rest abgebrochen' : ''}.`, f > 0);
if (instances.length) await selectInstance(instances[0].id);
}
@@ -934,12 +1027,13 @@ async function runCfImport() {
el('cf-progress').textContent = 'Übernehme …';
el('cf-progress').classList.remove('hidden');
const res = await window.api.cfImport(entries, copy);
const res = await importMitFortschritt(() => window.api.cfImport(entries, copy));
if (!res) { el('cf-do').disabled = false; el('cf-progress').classList.add('hidden'); return; }
await reload();
importAbgeschlossen('cf');
const n = res.imported.length;
const f = res.failed.length;
toast(`${n} CurseForge-Instanz${n === 1 ? '' : 'en'} übernommen${f ? ` · ${f} fehlgeschlagen` : ''}.`, f > 0);
toast(`${n} CurseForge-Instanz${n === 1 ? '' : 'en'} übernommen${f ? ` · ${f} fehlgeschlagen` : ''}${res.abgebrochen ? ' – Rest abgebrochen' : ''}.`, f > 0);
if (instances.length) await selectInstance(instances[0].id);
}
@@ -1008,13 +1102,14 @@ async function runMrImport() {
el('mr-progress').textContent = 'Übernehme …';
el('mr-progress').classList.remove('hidden');
const res = await window.api.mrImport(entries, copy);
const res = await importMitFortschritt(() => window.api.mrImport(entries, copy));
if (!res) { el('mr-do').disabled = false; el('mr-progress').classList.add('hidden'); return; }
await reload();
importAbgeschlossen('mr');
const n = res.imported.length;
const f = res.failed.length;
const ohneLink = res.imported.filter((i) => i.modpack === false).length;
toast(`${n} Instanz${n === 1 ? '' : 'en'} aus der Modrinth App übernommen${f ? ` · ${f} fehlgeschlagen` : ''}`
toast(`${n} Instanz${n === 1 ? '' : 'en'} aus der Modrinth App übernommen${f ? ` · ${f} fehlgeschlagen` : ''}${res.abgebrochen ? ' – Rest abgebrochen' : ''}`
+ (ohneLink ? ` · ${ohneLink}× Modpack-Verknüpfung nicht möglich (offline?)` : '') + '.', f > 0);
if (instances.length) await selectInstance(instances[0].id);
}
@@ -1080,12 +1175,13 @@ async function runMmcImport() {
el('mmc-progress').textContent = 'Übernehme …';
el('mmc-progress').classList.remove('hidden');
const res = await window.api.mmcImport(entries, copy);
const res = await importMitFortschritt(() => window.api.mmcImport(entries, copy));
if (!res) { el('mmc-do').disabled = false; el('mmc-progress').classList.add('hidden'); return; }
await reload();
importAbgeschlossen('mmc');
const n = res.imported.length;
const f = res.failed.length;
toast(`${n} Instanz${n === 1 ? '' : 'en'} übernommen${f ? ` · ${f} fehlgeschlagen` : ''}.`, f > 0);
toast(`${n} Instanz${n === 1 ? '' : 'en'} übernommen${f ? ` · ${f} fehlgeschlagen` : ''}${res.abgebrochen ? ' – Rest abgebrochen' : ''}.`, f > 0);
if (instances.length) await selectInstance(instances[0].id);
}
@@ -1206,12 +1302,13 @@ async function runTechnicImport() {
el('technic-progress').textContent = 'Übernehme …';
el('technic-progress').classList.remove('hidden');
const res = await window.api.technicImport(entries, copy);
const res = await importMitFortschritt(() => window.api.technicImport(entries, copy));
if (!res) { el('technic-do').disabled = false; el('technic-progress').classList.add('hidden'); return; }
await reload();
importAbgeschlossen('technic');
const n = res.imported.length;
const f = res.failed.length;
toast(`${n} Technic-Modpack${n === 1 ? '' : 's'} übernommen${f ? ` · ${f} fehlgeschlagen` : ''}.`, f > 0);
toast(`${n} Technic-Modpack${n === 1 ? '' : 's'} übernommen${f ? ` · ${f} fehlgeschlagen` : ''}${res.abgebrochen ? ' – Rest abgebrochen' : ''}.`, f > 0);
if (instances.length) await selectInstance(instances[0].id);
}
@@ -1427,6 +1524,55 @@ function closeContextMenu() {
el('ctx-menu').classList.add('hidden');
}
// ---------- Konto-Menü (Klick auf den Kontoknopf) ----------
function schliesseKontoMenue() {
el('konto-menu').classList.add('hidden');
}
async function oeffneKontoMenue() {
const menu = el('konto-menu');
if (!menu.classList.contains('hidden')) { schliesseKontoMenue(); return; }
closeContextMenu();
// feste, alphabetische Reihenfolge – sonst springt das aktive Konto nach oben
const konten = (await window.api.authAccounts()).slice().sort((a, b) => a.name.localeCompare(b.name, 'de', { sensitivity: 'base' }));
menu.innerHTML = '';
const eintrag = (inhalt, aktion, klasse = '') => {
const b = document.createElement('button');
b.className = 'ctx-item konto-eintrag ' + klasse;
b.innerHTML = inhalt;
b.addEventListener('click', async () => { schliesseKontoMenue(); await aktion(); });
menu.appendChild(b);
return b;
};
for (const k of konten) {
const b = eintrag(
`<span class="konto-haken">${k.active ? '✓' : ''}</span><img alt="" /><span class="konto-name">${escapeHtml(k.name)}</span>${k.playing ? '<span class="konto-spielt">spielt</span>' : ''}`,
async () => {
if (k.active) return;
await window.api.authSetActive(k.id);
await refreshAccountButton();
await refreshAllAccountLists();
await refreshSkinBackground();
toast('Aktives Konto: ' + k.name);
},
k.active ? 'aktiv' : '');
b.title = k.active ? 'Wird beim nächsten Start verwendet' : 'Für den nächsten Start verwenden';
const img = b.querySelector('img');
kontoKopf(k.id).then((src) => { img.src = src; });
}
if (konten.length) menu.appendChild(Object.assign(document.createElement('div'), { className: 'ctx-sep' }));
eintrag(`${svgIcon('plus')}<span class="konto-name">Konto hinzufügen…</span>`, () => openLogin({ addAccount: true }));
if (konten.some((k) => k.active)) eintrag(`${svgIcon('user')}<span class="konto-name">Skin ändern…</span>`, () => openSkin());
eintrag(`${svgIcon('users')}<span class="konto-name">Konten verwalten…</span>`, () => openLogin());
// unter dem Knopf, rechtsbündig – aber im Fenster bleiben
const r = el('btn-account').getBoundingClientRect();
menu.classList.remove('hidden');
const breite = menu.offsetWidth;
menu.style.left = Math.max(6, Math.min(r.right - breite, innerWidth - breite - 6)) + 'px';
menu.style.top = (r.bottom + 4) + 'px';
}
function formatVersionLabel(version) {
return String(version || '')
.replace(/^v/i, '')
@@ -1553,7 +1699,7 @@ async function applySkinBackground(uuidOverride) {
} else if (s.bgSkin) {
uuid = s.bgSkin;
}
if (!uuid) { root.style.setProperty('--bg-skin', 'none'); return; }
if (!uuid) { beendeSkinLive(); root.style.setProperty('--bg-skin', 'none'); return; }
root.style.setProperty('--bg-skin-opacity', String((s.bgSkinOpacity ?? 55) / 100));
@@ -1566,6 +1712,11 @@ async function applySkinBackground(uuidOverride) {
if (!modell) { try { modell = await window.api.skinModelFor(uuid); } catch { modell = 'classic'; } }
let umhang = null;
try { const r = await window.api.capeForUuid(uuid); umhang = r ? r.dataUri : null; } catch { /* ohne Umhang */ }
// 1a) bewegt (Atmen, Kopf folgt der Maus) – sonst wie bisher als Standbild
if (await zeigeLebendigeFigur(`https://crafthead.net/skin/${uuid}`, s.bgSkinPose || 'walking', { modell, umhang })) {
root.style.setProperty('--bg-skin', 'none');
return;
}
const local = await renderSkin3D(`https://crafthead.net/skin/${uuid}`, s.bgSkinPose || 'walking', { modell, umhang });
if (local) { root.style.setProperty('--bg-skin', `url("${local}")`); return; }
@@ -1586,6 +1737,119 @@ async function applySkinBackground(uuidOverride) {
root.style.setProperty('--bg-skin', 'none');
}
// ---------- Lebendige Skin-Figur im Hintergrund ----------
// Statt eines starren Bildes läuft eine echte 3D-Figur in der gewählten Pose:
// sie atmet, wiegt sich leicht, der Umhang schwingt, und der Kopf folgt dem
// Mauszeiger. Pausiert, solange das Fenster minimiert/im Hintergrund ist.
let skinLive = null;
function skinLiveGroesse() {
const r = document.querySelector('.list-area').getBoundingClientRect();
// wie der frühere Hintergrund: 52 % Breite × 88 % Höhe, Seitenverhältnis 420:700
const w = Math.max(60, Math.min(r.width * 0.52, r.height * 0.88 * 0.6));
return { w: Math.round(w), h: Math.round(w / 0.6) };
}
function beendeSkinLive() {
if (!skinLive) return;
const { viewer, abmelden } = skinLive;
skinLive = null;
abmelden();
try { viewer.dispose(); } catch { /* egal */ }
const c = el('skin-live');
if (c) c.classList.add('hidden');
const flaeche = document.querySelector('.list-area');
if (flaeche) flaeche.classList.remove('skin-lebt');
}
async function zeigeLebendigeFigur(quelle, poseKey, opts = {}) {
beendeSkinLive();
const canvas = el('skin-live');
if (POPOUT || !canvas || typeof skinview3d === 'undefined' || !bewegungErlaubt()) return false;
const { w, h } = skinLiveGroesse();
let viewer;
try {
viewer = new skinview3d.SkinViewer({ canvas, width: w, height: h, skin: quelle, model: opts.modell === 'slim' ? 'slim' : 'default' });
await new Promise((resolve, reject) => {
const t = setTimeout(reject, 8000);
const check = () => {
if (viewer.playerObject && viewer.playerObject.skin && viewer.playerObject.skin.map) { clearTimeout(t); resolve(); }
else setTimeout(check, 60);
};
check();
});
} catch {
try { if (viewer) viewer.dispose(); } catch { /* egal */ }
return false;
}
const pose = SKIN_POSES[poseKey] || SKIN_POSES.walking;
viewer.fov = pose.fov;
viewer.zoom = pose.zoom;
viewer.autoRotate = false;
viewer.controls.enabled = false;
if (opts.umhang) { try { await viewer.loadCape(opts.umhang); } catch { /* ohne Umhang */ } }
// Grundhaltung merken: Pose einmal anwenden und Kopf/Umhang/Höhe festhalten
const p = viewer.playerObject;
pose.apply(p);
const kopfBasis = p.skin.head.rotation.clone();
const umhangBasis = p.cape ? p.cape.rotation.x : 0;
const hoeheBasis = p.position.y;
const koerperBasis = p.rotation.y;
const ziel = { yaw: 0, pitch: 0 };
const kopf = { yaw: 0, pitch: 0 };
const maus = (e) => {
const r = canvas.getBoundingClientRect();
const kx = r.left + r.width * 0.5;
const ky = r.top + r.height * 0.22; // Kopf sitzt im oberen Fünftel der Figur
ziel.yaw = Math.max(-0.75, Math.min(0.75, (e.clientX - kx) / 520));
ziel.pitch = Math.max(-0.4, Math.min(0.45, (e.clientY - ky) / 650));
};
const weg = () => { ziel.yaw = 0; ziel.pitch = 0; };
viewer.animation = new skinview3d.FunctionAnimation((pl, t) => {
pose.apply(pl); // Arme/Beine jedes Mal frisch aus der Pose
const atem = Math.sin(t * 1.8);
pl.position.y = hoeheBasis + atem * 0.18; // Brustkorb hebt und senkt sich
pl.rotation.y = koerperBasis + Math.sin(t * 0.55) * 0.05;
pl.skin.leftArm.rotation.z += atem * 0.03;
pl.skin.rightArm.rotation.z -= atem * 0.03;
if (pl.cape) pl.cape.rotation.x = umhangBasis + 0.06 + Math.sin(t * 1.3) * 0.05;
kopf.yaw += (ziel.yaw - kopf.yaw) * 0.07; // weich nachführen
kopf.pitch += (ziel.pitch - kopf.pitch) * 0.07;
// ohne Maus schaut die Figur halb zum Betrachter, sonst dem Zeiger hinterher
pl.skin.head.rotation.set(kopfBasis.x + kopf.pitch, kopfBasis.y - koerperBasis * 0.45 + kopf.yaw, kopfBasis.z);
});
const sicht = () => { viewer.renderPaused = document.hidden || !document.hasFocus(); };
const ro = new ResizeObserver(() => {
const g = skinLiveGroesse();
viewer.setSize(g.w, g.h);
});
ro.observe(document.querySelector('.list-area'));
window.addEventListener('mousemove', maus);
document.documentElement.addEventListener('mouseleave', weg);
document.addEventListener('visibilitychange', sicht);
window.addEventListener('blur', sicht);
window.addEventListener('focus', sicht);
sicht();
skinLive = {
viewer,
abmelden: () => {
ro.disconnect();
window.removeEventListener('mousemove', maus);
document.documentElement.removeEventListener('mouseleave', weg);
document.removeEventListener('visibilitychange', sicht);
window.removeEventListener('blur', sicht);
window.removeEventListener('focus', sicht);
},
};
canvas.classList.remove('hidden');
document.querySelector('.list-area').classList.add('skin-lebt');
return true;
}
// ---------- Skin-Hintergrund: automatischer Kontenwechsel ----------
let skinCycleTimer = null;
let skinCycleIndex = 0;
@@ -1668,6 +1932,15 @@ const SKIN_POSES = {
p.skin.leftLeg.rotation.set(-1.5, 0, 0.12); // Beine angewinkelt
p.skin.rightLeg.rotation.set(-1.5, 0, -0.12);
} },
kopf: { label: 'Büste', fov: 28, zoom: 2.15, apply: (p) => {
p.rotation.set(0.08, -0.5, 0);
p.position.y = -9.5; // Kopf in die Bildmitte
p.skin.head.rotation.set(0.05, 0.22, 0); // Blick leicht zum Betrachter
p.skin.leftArm.rotation.set(0, 0, 0.06);
p.skin.rightArm.rotation.set(0, 0, -0.06);
p.skin.leftLeg.rotation.set(0, 0, 0);
p.skin.rightLeg.rotation.set(0, 0, 0);
} },
spooky: { label: 'Unheimlich', fov: 32, zoom: 0.6, apply: (p) => {
p.rotation.set(-0.06, -0.45, 0); // gedreht + leichte Untersicht
p.skin.head.rotation.set(0.22, -0.2, 0.28); // Kopf schief gelegt
@@ -1685,7 +1958,15 @@ const SKIN_POSES = {
* die noch gar nicht hochgeladen ist.
* modell: 'default' (breite Arme) oder 'slim' (Alex)
*/
async function renderSkin3D(quelle, poseKey, opts = {}) {
let renderWarteschlange = Promise.resolve();
function renderSkin3D(quelle, poseKey, opts = {}) {
// nacheinander: alle Aufrufe zeichnen auf dieselbe Leinwand (#skin-canvas)
const auftrag = renderWarteschlange.then(() => renderSkin3DJetzt(quelle, poseKey, opts));
renderWarteschlange = auftrag.catch(() => null);
return auftrag;
}
async function renderSkin3DJetzt(quelle, poseKey, opts = {}) {
if (typeof skinview3d === 'undefined') return null;
const canvas = el('skin-canvas');
const breite = opts.breite || 420;
@@ -1848,7 +2129,7 @@ async function refreshSelectionPanel() {
if (iconData) el('p-icon').innerHTML = `<img src="${iconData}" alt="" />`;
else { el('p-icon').innerHTML = iconOf(inst.icon, 60); }
el('p-name').textContent = inst.name;
el('p-groupline').textContent = inst.group ? `Gruppe: ${inst.group}` : 'Ohne Gruppe';
el('p-groupline').textContent = inst.group ? `Gruppe: ${gruppenAnzeige(inst.group)}` : 'Ohne Gruppe';
const loaderTxt = inst.minecraft.loader && inst.minecraft.loader !== 'vanilla'
? ' · ' + (LOADER_LABEL[inst.minecraft.loader] || inst.minecraft.loader) : '';
@@ -1924,6 +2205,8 @@ function sucheSchliessen() {
if (document.activeElement === el('search')) el('search').blur();
}
let instanzenEingeblendet = false;
function render() {
const q = el('search').value.trim().toLowerCase();
const grid = el('grid');
@@ -1935,59 +2218,113 @@ function render() {
el('empty').classList.toggle('hidden', instances.length !== 0);
grid.classList.toggle('hidden', instances.length === 0);
// nach Gruppen bündeln
const groups = new Map();
for (const inst of filtered) {
const g = (inst.group && inst.group.trim()) || '';
if (!groups.has(g)) groups.set(g, []);
groups.get(g).push(inst);
// nach Gruppen (und Untergruppen, "Modpacks/Survival") bündeln
const baum = baueGruppenbaum(filtered);
const showHeaders = baum.kinder.size > 0;
for (const kind of sortierteKinder(baum)) grid.appendChild(baueGruppenSektion(kind, 0, showHeaders));
// Instanzen ohne Gruppe zuletzt
if (baum.instanzen.length) {
grid.appendChild(baueGruppenSektion({ name: 'Ohne Gruppe', pfad: '', kinder: new Map(), instanzen: baum.instanzen }, 0, showHeaders));
}
// benannte Gruppen alphabetisch, "ohne Gruppe" zuletzt
const names = [...groups.keys()].sort((a, b) => (a === '' ? 1 : b === '' ? -1 : a.localeCompare(b)));
const showHeaders = names.some((n) => n !== '');
for (const g of names) {
const list = groups.get(g);
const section = document.createElement('div');
section.className = 'group-section' + (collapsedGroups.has(g) ? ' collapsed' : '');
if (showHeaders) {
const header = document.createElement('div');
header.className = 'group-header' + (collapsedGroups.has(g) ? ' collapsed' : '');
header.innerHTML = `<span class="chev">${collapsedGroups.has(g) ? '▸' : '▾'}</span> ${escapeHtml(g || 'Ohne Gruppe')} <span class="group-count">(${list.length})</span>`;
header.addEventListener('click', () => {
if (collapsedGroups.has(g)) collapsedGroups.delete(g); else collapsedGroups.add(g);
render();
});
section.appendChild(header);
// Beim ersten Anzeigen kurz nacheinander einblenden – nicht nach der
// Startanimation (da ist der Fensteraufbau schon der Auftritt)
if (!instanzenEingeblendet && instances.length) {
instanzenEingeblendet = true;
if (bewegungErlaubt() && !MIT_STARTANIMATION && !POPOUT) {
grid.querySelectorAll('.group-header, .card').forEach((k, i) => k.style.setProperty('--i', String(Math.min(i, 24))));
grid.classList.add('einblenden');
setTimeout(() => grid.classList.remove('einblenden'), 1400);
}
const gridEl = document.createElement('div');
gridEl.className = 'group-grid';
for (const inst of list) gridEl.appendChild(buildCard(inst));
section.appendChild(gridEl);
// Drop-Ziel: Instanz per Drag&Drop in diese Gruppe verschieben
section.addEventListener('dragover', (e) => { e.preventDefault(); section.classList.add('drop-hover'); });
section.addEventListener('dragleave', (e) => { if (!section.contains(e.relatedTarget)) section.classList.remove('drop-hover'); });
section.addEventListener('drop', async (e) => {
e.preventDefault();
section.classList.remove('drop-hover');
const dropId = e.dataTransfer.getData('text/plain');
const dragged = instances.find((i) => i.id === dropId);
if (dragged && (dragged.group || '') !== g) {
await window.api.updateInstance(dropId, { group: g });
await reload();
toast('Verschoben nach: ' + (g || 'Ohne Gruppe'));
}
});
grid.appendChild(section);
}
status(`${instances.length} Instanz${instances.length === 1 ? '' : 'en'}`);
}
// ---------- Gruppen und Untergruppen ----------
// Eine Untergruppe steckt im Gruppennamen, getrennt mit "/": "Modpacks/Survival".
// Ohne "/" bleibt alles wie bisher (eine Ebene).
function gruppenPfad(g) {
return String(g || '').split(/[\/\\]/).map((t) => t.trim()).filter(Boolean).join('/');
}
function gruppenAnzeige(g) {
return gruppenPfad(g).split('/').join(' › ');
}
function baueGruppenbaum(liste) {
const wurzel = { name: '', pfad: '', kinder: new Map(), instanzen: [] };
for (const inst of liste) {
let knoten = wurzel;
const pfad = gruppenPfad(inst.group);
if (pfad) {
for (const teil of pfad.split('/')) {
if (!knoten.kinder.has(teil)) {
knoten.kinder.set(teil, { name: teil, pfad: knoten.pfad ? knoten.pfad + '/' + teil : teil, kinder: new Map(), instanzen: [] });
}
knoten = knoten.kinder.get(teil);
}
}
knoten.instanzen.push(inst);
}
return wurzel;
}
const anzahlImZweig = (k) => k.instanzen.length + [...k.kinder.values()].reduce((s, c) => s + anzahlImZweig(c), 0);
const sortierteKinder = (k) => [...k.kinder.values()].sort((a, b) => a.name.localeCompare(b.name, 'de', { numeric: true }));
// Abschnitt für eine Gruppe samt Untergruppen (rekursiv)
function baueGruppenSektion(knoten, tiefe, showHeaders) {
const g = knoten.pfad;
const zu = collapsedGroups.has(g);
const section = document.createElement('div');
section.className = 'group-section' + (tiefe ? ' group-sub' : '') + (zu ? ' collapsed' : '');
if (showHeaders) {
const header = document.createElement('div');
header.className = 'group-header' + (zu ? ' collapsed' : '');
header.title = g ? gruppenAnzeige(g) : 'Instanzen ohne Gruppe';
header.innerHTML = `<span class="chev">${zu ? '▸' : '▾'}</span> ${escapeHtml(knoten.name)} <span class="group-count">(${anzahlImZweig(knoten)})</span>`;
header.addEventListener('click', () => {
if (collapsedGroups.has(g)) collapsedGroups.delete(g); else collapsedGroups.add(g);
render();
});
section.appendChild(header);
}
const inhalt = document.createElement('div');
inhalt.className = 'group-body';
if (knoten.instanzen.length) {
const gridEl = document.createElement('div');
gridEl.className = 'group-grid';
for (const inst of knoten.instanzen) gridEl.appendChild(buildCard(inst));
inhalt.appendChild(gridEl);
}
for (const kind of sortierteKinder(knoten)) inhalt.appendChild(baueGruppenSektion(kind, tiefe + 1, showHeaders));
section.appendChild(inhalt);
// Drop-Ziel: Instanz per Drag&Drop in diese (Unter-)Gruppe verschieben –
// stopPropagation, damit die innerste Gruppe gewinnt, nicht die äußere
section.addEventListener('dragover', (e) => { e.preventDefault(); e.stopPropagation(); section.classList.add('drop-hover'); });
section.addEventListener('dragleave', (e) => { if (!section.contains(e.relatedTarget)) section.classList.remove('drop-hover'); });
section.addEventListener('drop', async (e) => {
e.preventDefault();
e.stopPropagation();
section.classList.remove('drop-hover');
const dropId = e.dataTransfer.getData('text/plain');
const dragged = instances.find((i) => i.id === dropId);
if (dragged && gruppenPfad(dragged.group) !== g) {
await window.api.updateInstance(dropId, { group: g });
await reload();
toast('Verschoben nach: ' + (g ? gruppenAnzeige(g) : 'Ohne Gruppe'));
}
});
return section;
}
function fillGroupDatalist() {
const groups = [...new Set(instances.map((i) => i.group).filter(Boolean))].sort();
// alle Gruppen samt ihrer übergeordneten Ebenen vorschlagen
const alle = new Set();
for (const i of instances) {
const teile = gruppenPfad(i.group).split('/').filter(Boolean);
for (let n = 1; n <= teile.length; n++) alle.add(teile.slice(0, n).join('/'));
}
const groups = [...alle].sort((a, b) => a.localeCompare(b, 'de', { numeric: true }));
el('group-list').innerHTML = groups.map((g) => `<option value="${escapeHtml(g)}"></option>`).join('');
}
@@ -2246,7 +2583,7 @@ async function saveInstance() {
const payload = {
name,
group: el('f-group').value.trim(),
group: gruppenPfad(el('f-group').value),
icon: el('f-icon').value,
notes: el('f-notes').value.trim(),
minecraft: {
@@ -4993,6 +5330,8 @@ async function openSettings(pane) {
sel.disabled = !!s.bgSkinCycle;
el('s-sortby').value = s.sortBy || 'lastPlayed';
el('s-iso3d').checked = s.iso3dIcons !== false;
el('s-start-animation').checked = s.startAnimation !== false;
el('s-ui-animationen').checked = s.uiAnimationen !== false;
el('s-bedrock').checked = s.bedrockEnabled !== false;
el('s-notify').checked = s.desktopNotifications !== false;
el('s-friends').checked = s.friendsEnabled !== false;
@@ -5127,6 +5466,8 @@ async function saveSettings() {
bgSkinCycleSeconds: Math.max(5, numOrNull(el('s-bgskin-cycle-seconds').value) || 30),
sortBy: el('s-sortby').value,
iso3dIcons: el('s-iso3d').checked,
startAnimation: el('s-start-animation').checked,
uiAnimationen: el('s-ui-animationen').checked,
bedrockEnabled: el('s-bedrock').checked,
desktopNotifications: el('s-notify').checked,
friendsEnabled: el('s-friends').checked,
@@ -5166,6 +5507,7 @@ async function saveSettings() {
// Theme hier direkt anwenden (für dieses Fenster selbst); alles, was das
// Hauptfenster betrifft (Bedrock-Sichtbarkeit, Skin-Hintergrund,
// Instanzliste), zieht das Hauptfenster über "settings:changed" selbst nach
setzeUiAnimationen(patch.uiAnimationen);
applyTheme(patch.theme);
iso3dEnabled = patch.iso3dIcons;
hide('modal-settings');
@@ -5424,6 +5766,22 @@ function promptGlobalRestore() {
}
// ---------- Microsoft-Login / Kontoverwaltung ----------
// Kopf/Büste eines Kontos selbst in 3D zeichnen – so steht die 2. Ebene
// (Mütze, Haare, Kragen, Ärmel) wie im Spiel plastisch ab. Fertige Bilder von
// crafthead zeigen sie nur flach aufgemalt; die dienen nur noch als Rückfall.
const kontoKoepfe = new Map(); // uuid -> data:-Bild
async function kontoKopf(uuid) {
if (kontoKoepfe.has(uuid)) return kontoKoepfe.get(uuid);
const auftrag = (async () => {
let modell = 'classic';
try { modell = await window.api.skinModelFor(uuid); } catch { /* breite Arme */ }
const bild = await renderSkin3D(`https://crafthead.net/skin/${uuid}`, 'kopf', { modell, breite: 128, hoehe: 128 });
return bild || `https://crafthead.net/armor/bust/${uuid}?size=64`;
})();
kontoKoepfe.set(uuid, auftrag);
return auftrag;
}
async function refreshAccountButton() {
const acc = await window.api.authStatus();
const av = el('account-av');
@@ -5431,7 +5789,7 @@ async function refreshAccountButton() {
av.innerHTML = '';
const img = document.createElement('img');
img.className = 'acct-head';
img.src = `https://crafthead.net/bust/${acc.profile.id}?size=64`;
img.src = await kontoKopf(acc.profile.id);
img.addEventListener('error', () => { av.innerHTML = svgIcon('user'); });
av.appendChild(img);
el('account-name').textContent = acc.profile.name;
@@ -5462,7 +5820,7 @@ async function renderAccounts(boxId = 'login-accounts') {
const av = document.createElement('img');
av.className = 'account-av';
av.src = `https://crafthead.net/avatar/${a.id}?size=64`;
av.src = await kontoKopf(a.id);
av.addEventListener('error', () => {
const d = document.createElement('div');
d.className = 'account-av'; d.textContent = '🎮';
@@ -5662,7 +6020,9 @@ function wire() {
// schließen bei Klick daneben, Rechtsklick ins Leere, Scrollen oder ESC
document.addEventListener('click', (e) => {
if (!e.target.closest('#ctx-menu')) closeContextMenu();
if (!e.target.closest('#konto-menu') && !e.target.closest('#btn-account')) schliesseKontoMenue();
});
window.addEventListener('blur', schliesseKontoMenue);
document.addEventListener('contextmenu', (e) => {
if (!e.target.closest('.card') && !e.target.closest('#ctx-menu')) closeContextMenu();
});
@@ -5771,6 +6131,12 @@ function wire() {
});
el('technic-list').addEventListener('change', updateTechnicButton);
el('technic-do').addEventListener('click', runTechnicImport);
window.api.onImportFortschritt(zeigeImportFortschritt);
el('import-lauf-abbrechen').addEventListener('click', async () => {
el('import-lauf-abbrechen').disabled = true;
el('import-lauf-titel').textContent = 'Wird abgebrochen …';
await window.api.importAbbrechen();
});
window.api.onMcProgress((p) => {
el('mc-progress').textContent = `Übernehme … ${p.current}/${p.total}`;
el('mc-progress').classList.remove('hidden');
@@ -6212,7 +6578,7 @@ function wire() {
// Design-Kacheln setzen die (versteckte) Auswahl und zeigen es sofort an
document.querySelectorAll('.design-wahl').forEach((b) => b.addEventListener('click', () => {
el('s-theme').value = b.dataset.design;
applyTheme(b.dataset.design);
designUeberblenden(b.dataset.design);
}));
el('s-bgskin-opacity').addEventListener('input', (e) => {
el('s-bgskin-val').textContent = e.target.value;
@@ -6298,7 +6664,7 @@ function wire() {
}
// Microsoft-Login / Kontoverwaltung
el('btn-account').addEventListener('click', openLogin);
el('btn-account').addEventListener('click', (e) => { e.stopPropagation(); oeffneKontoMenue(); });
el('login-add').addEventListener('click', addAccount);
el('login-url').addEventListener('click', (e) => {
e.preventDefault();
@@ -6355,6 +6721,7 @@ function wire() {
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
if (!el('ctx-menu').classList.contains('hidden')) { closeContextMenu(); return; }
if (!el('konto-menu').classList.contains('hidden')) { schliesseKontoMenue(); return; }
if (!el('modal-prompt').classList.contains('hidden')) { closePrompt(null); return; }
const open = [...document.querySelectorAll('.modal:not(.hidden)')].pop();
if (open && open.id === 'modal-record' && recordingActive()) { confirmStopAndCloseRecording(); return; }
@@ -6533,6 +6900,7 @@ window.addEventListener('DOMContentLoaded', async () => {
applyIcons();
try {
const s = await window.api.getSettings();
setzeUiAnimationen(s.uiAnimationen);
applyTheme(s.theme);
iso3dEnabled = s.iso3dIcons !== false;
consoleMaxChars = Math.max(50000, (s.consoleMaxLines || 100000) * 3);
@@ -6615,7 +6983,8 @@ window.addEventListener('DOMContentLoaded', async () => {
// unsichtbar (Sidebar/Symbolleiste sind dort ausgeblendet) und daher nur
// im Hauptfenster sinnvoll
window.api.onSettingsChanged((s) => {
applyTheme(s.theme);
setzeUiAnimationen(s.uiAnimationen);
designUeberblenden(s.theme);
iso3dEnabled = s.iso3dIcons !== false;
schnellstartBelegung = s.schnellstart || {};
if (!POPOUT) {
@@ -6629,7 +6998,8 @@ window.addEventListener('DOMContentLoaded', async () => {
// Fenster (Skin-/Import-Popup) betrifft ebenfalls nur die Anzeige im
// Hauptfenster (Skin-Hintergrund, Seitenleiste) – Popups zeigen beides nicht an
window.api.onSkinChanged((info) => {
if (!POPOUT) refreshSkinBackground();
kontoKoepfe.clear();
if (!POPOUT) { refreshSkinBackground(); refreshAccountButton(); }
// im Editor gesetzt -> offenes Skin-Fenster zeigt sonst noch den alten Stand
if (POPOUT === 'skin' && (info.quelle === 'editor' || info.quelle === 'umhang') && !el('modal-skin').classList.contains('hidden')) openSkin();
});