Upload via GUI (28 Dateien)

This commit is contained in:
Git Manager GUI
2026-08-02 21:56:31 +02:00
parent b250ef16fd
commit 129ab2d939
9 changed files with 876 additions and 46 deletions

View File

@@ -1,4 +1,4 @@
{ {
"curseforgeProxyUrl": "", "curseforgeProxyUrl": "",
"curseforgeProxyToken": "GetqSQ6K0z4OHet2SCUA5dHBqoKmFCl3a7LT6bZVVS0FAb07ZxVq7r7HS3dUbH6J" "curseforgeProxyToken": ""
} }

View File

@@ -424,8 +424,12 @@
<h5>CurseForge</h5> <h5>CurseForge</h5>
<label class="field"> <label class="field">
<span>Eigener API-Key <small class="opt">(optional)</small></span> <span>Eigener API-Key <small class="opt">(optional)</small></span>
<input id="s-cf-key" type="password" placeholder="leer lassen, um die Weiterleitung unten zu nutzen" /> <div class="path-row">
<small class="hint">Kostenlos unter console.curseforge.com. Ist hier ein Schlüssel eingetragen, geht der Launcher direkt zu CurseForge und nutzt keine Weiterleitung.</small> <input id="s-cf-key" type="password" placeholder="leer lassen, um die Weiterleitung unten zu nutzen" />
<button type="button" id="s-cf-test" class="btn">Prüfen</button>
</div>
<small class="hint">Schlüssel von CurseForge for Studios (Profil → Schlüssel erstellen, beginnt mit cfc_pat_…) oder klassischer Organisationsschlüssel. Nach dem Eintragen speichern. „Prüfen“ testet den Key bei CurseForge.</small>
<small id="s-cf-test-status" class="hint"></small>
</label> </label>
<div class="field-row"> <div class="field-row">
<label class="field"> <label class="field">
@@ -568,6 +572,27 @@
</div> </div>
</div> </div>
<!-- Eingabe-Dialog (ersetzt window.prompt, das in Electron nicht geht) -->
<div id="modal-prompt" class="modal hidden">
<div class="modal-card modal-sm">
<div class="modal-head">
<h3 id="prompt-title">Eingabe</h3>
<button class="modal-close" data-close></button>
</div>
<div class="modal-body">
<p id="prompt-text" class="hint"></p>
<label class="field" style="margin-top:10px">
<span id="prompt-label">Name</span>
<input id="prompt-input" type="text" autocomplete="off" />
</label>
</div>
<div class="modal-foot">
<button class="btn" data-close>Abbrechen</button>
<button id="prompt-ok" class="btn btn-primary">OK</button>
</div>
</div>
</div>
<!-- ===================== Dialog: Mods (Modrinth) ===================== --> <!-- ===================== Dialog: Mods (Modrinth) ===================== -->
<div id="modal-mods" class="modal hidden"> <div id="modal-mods" class="modal hidden">
<div class="modal-card modal-lg"> <div class="modal-card modal-lg">
@@ -595,6 +620,10 @@
</div> </div>
<div class="mods-filters"> <div class="mods-filters">
<select id="m-source" title="Quelle">
<option value="modrinth">Modrinth</option>
<option value="curseforge">CurseForge</option>
</select>
<select id="m-sort" title="Sortierung"> <select id="m-sort" title="Sortierung">
<option value="downloads">Beliebt</option> <option value="downloads">Beliebt</option>
<option value="relevance">Relevanz</option> <option value="relevance">Relevanz</option>
@@ -710,6 +739,10 @@
<h4>Ressourcenpakete <button id="content-open-rp" class="btn btn-mini" data-ic="folder"></button></h4> <h4>Ressourcenpakete <button id="content-open-rp" class="btn btn-mini" data-ic="folder"></button></h4>
<div id="content-rp" class="mods-list"></div> <div id="content-rp" class="mods-list"></div>
</div> </div>
<div class="mods-col">
<h4>Datenpakete <button id="content-open-dp" class="btn btn-mini" data-ic="folder"></button></h4>
<div id="content-dp" class="mods-list"></div>
</div>
</div> </div>
</div> </div>
<div class="modal-foot"> <div class="modal-foot">
@@ -727,6 +760,12 @@
<button class="modal-close" data-close></button> <button class="modal-close" data-close></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div class="screens-toolbar">
<button id="screens-select-all" class="btn btn-mini">Alle auswählen</button>
<button id="screens-select-none" class="btn btn-mini">Auswahl aufheben</button>
<button id="screens-delete-sel" class="btn btn-danger btn-mini" disabled>Auswahl löschen</button>
<span id="screens-sel-hint" class="hint">Klick auf Vorschaubild zum Auswählen</span>
</div>
<div id="screens-grid" class="screens-grid"></div> <div id="screens-grid" class="screens-grid"></div>
</div> </div>
<div class="modal-foot"> <div class="modal-foot">
@@ -949,8 +988,34 @@
</div> </div>
<div id="import-pane-modpack" class="import-pane hidden"> <div id="import-pane-modpack" class="import-pane hidden">
<p class="import-copy">Importiert ein Modpack als neue Instanz.</p> <p class="import-copy">Modpack aus dem Katalog installieren oder als Datei importieren.</p>
<p class="hint">Unterstützt werden Modrinth (.mrpack oder ZIP mit modrinth.index.json) und CurseForge (ZIP mit manifest.json). Für CurseForge ist ein eigener API-Key in den Netzwerk-Einstellungen nötig.</p> <p class="hint">Suche läuft über Modrinth. Datei-Import unterstützt zusätzlich CurseForge-ZIP (manifest.json) dafür ist ein API-Key oder eine Weiterleitung in den Netzwerk-Einstellungen nötig.</p>
<div class="field-row" style="margin-top:12px;align-items:flex-end">
<label class="field" style="flex:1">
<span>Suche (Modrinth)</span>
<input id="mp-query" type="text" placeholder="z. B. Fabulously Optimized, Create …" autocomplete="off" />
</label>
<label class="field" style="width:140px">
<span>MC-Version <small class="opt">(opt.)</small></span>
<input id="mp-mc" type="text" placeholder="1.21.1" autocomplete="off" />
</label>
<button id="mp-search-btn" class="btn btn-primary">Suchen</button>
</div>
<div class="field-row" style="margin-top:8px">
<label class="field">
<span>Sortierung</span>
<select id="mp-sort">
<option value="relevance">Relevanz</option>
<option value="downloads">Downloads</option>
<option value="follows">Follower</option>
<option value="newest">Neu</option>
<option value="updated">Aktualisiert</option>
</select>
</label>
</div>
<div id="mp-status" class="hint" style="margin-top:8px">Gib einen Suchbegriff ein oder lasse das Feld leer für beliebte Packs.</div>
<div id="mp-results" class="mods-list" style="margin-top:10px;max-height:280px;overflow:auto"></div>
</div> </div>
<div id="import-pane-mc" class="import-pane hidden"> <div id="import-pane-mc" class="import-pane hidden">

View File

@@ -478,6 +478,10 @@ async function prepareAndLaunch(opts, onProgress) {
// Fenstergröße bzw. maximiert starten (Minecraft-Argumente) // Fenstergröße bzw. maximiert starten (Minecraft-Argumente)
if (opts.mcMaximized) args.push('--fullscreen'); if (opts.mcMaximized) args.push('--fullscreen');
else if (opts.mcWidth && opts.mcHeight) args.push('--width', String(opts.mcWidth), '--height', String(opts.mcHeight)); else if (opts.mcWidth && opts.mcHeight) args.push('--width', String(opts.mcWidth), '--height', String(opts.mcHeight));
// Ab 1.20: direkt einem Server beitreten (Quick Play)
if (opts.quickPlayServer) {
args.push('--quickPlayMultiplayer', String(opts.quickPlayServer));
}
ensureDir(opts.gameDir); ensureDir(opts.gameDir);

View File

@@ -287,7 +287,29 @@ ipcMain.handle('mc:versions', () => services.getMcVersions(app.getPath('userData
// IPC Mods (Modrinth) // IPC Mods (Modrinth)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
ipcMain.handle('mods:search', (_e, opts) => services.modrinthSearch(opts)); ipcMain.handle('curseforge:testKey', async (_e, apiKey) => {
try { return await services.curseforgeTestKey(apiKey); }
catch (err) { return { ok: false, message: err.message || String(err) }; }
});
ipcMain.handle('mods:search', async (_e, opts) => {
const o = opts || {};
const source = o.source || 'modrinth';
try {
if (source === 'curseforge') {
const zugang = services.curseforgeZugang(store.getSettings());
return await services.curseforgeSearch(zugang, o);
}
return await services.modrinthSearch(o);
} catch (err) {
return {
total: 0,
hits: [],
error: (err && err.message) ? err.message : String(err),
needKey: !!(err && err.code === 'need-key'),
};
}
});
ipcMain.handle('mods:list', (_e, id, art) => store.listMods(id, art || 'mod')); ipcMain.handle('mods:list', (_e, id, art) => store.listMods(id, art || 'mod'));
@@ -308,25 +330,46 @@ ipcMain.handle('mods:install', async (_e, id, project, art) => {
const done = new Set(store.listMods(id, art || 'mod').map((m) => m.projectId)); const done = new Set(store.listMods(id, art || 'mod').map((m) => m.projectId));
const added = []; const added = [];
let firstError = null; let firstError = null;
const istCf = !!(project && (project.source === 'curseforge' || String(project.projectId || '').startsWith('cf-')));
const zugang = istCf ? services.curseforgeZugang(store.getSettings()) : null;
// installiert ein Projekt und rekursiv seine PFLICHT-Abhängigkeiten // installiert ein Projekt und rekursiv seine PFLICHT-Abhängigkeiten
async function installOne(proj, isDep) { async function installOne(proj, isDep) {
if (done.has(proj.projectId)) return; if (done.has(proj.projectId)) return;
done.add(proj.projectId); done.add(proj.projectId);
try { try {
const res = await services.modrinthInstall({ let res;
projectId: proj.projectId, title: proj.title, gameVersion, loader, modsDir, art: art || 'mod', if (String(proj.projectId || '').startsWith('cf-') || proj.source === 'curseforge') {
}); res = await services.curseforgeInstall({
if (!res.ok) { zugang, projectId: proj.projectId, title: proj.title,
if (!isDep && !firstError) firstError = res.message; gameVersion, loader, modsDir, art: art || 'mod',
return; });
} if (!res.ok) {
store.addMod(id, res.entry, art || 'mod'); if (!isDep && !firstError) firstError = res.message;
added.push(res.entry); return;
for (const dep of res.dependencies || []) { }
if (dep.dependency_type === 'required' && dep.project_id && !done.has(dep.project_id)) { store.addMod(id, res.entry, art || 'mod');
const title = await services.modrinthProjectTitle(dep.project_id); added.push(res.entry);
await installOne({ projectId: dep.project_id, title }, true); for (const dep of res.dependencies || []) {
if (dep.required && dep.projectId && !done.has(dep.projectId)) {
await installOne({ projectId: dep.projectId, title: dep.projectId, source: 'curseforge' }, true);
}
}
} else {
res = await services.modrinthInstall({
projectId: proj.projectId, title: proj.title, gameVersion, loader, modsDir, art: art || 'mod',
});
if (!res.ok) {
if (!isDep && !firstError) firstError = res.message;
return;
}
store.addMod(id, res.entry, art || 'mod');
added.push(res.entry);
for (const dep of res.dependencies || []) {
if (dep.dependency_type === 'required' && dep.project_id && !done.has(dep.project_id)) {
const title = await services.modrinthProjectTitle(dep.project_id);
await installOne({ projectId: dep.project_id, title }, true);
}
} }
} }
} catch (err) { } catch (err) {
@@ -384,6 +427,11 @@ ipcMain.handle('servers:remove', (_e, id, ip) => {
return servers.entferne(store.gameDir(id), ip); return servers.entferne(store.gameDir(id), ip);
}); });
ipcMain.handle('servers:rename', (_e, id, ip, neuerName) => {
if (!store.getInstance(id)) return { ok: false, message: 'Instanz nicht gefunden.' };
return servers.benenneUm(store.gameDir(id), ip, neuerName);
});
// denselben Server in weitere Instanzen übertragen // denselben Server in weitere Instanzen übertragen
ipcMain.handle('servers:copyTo', (_e, zielIds, eintrag) => { ipcMain.handle('servers:copyTo', (_e, zielIds, eintrag) => {
const erledigt = []; const erledigt = [];
@@ -612,6 +660,7 @@ function listDir(dir, dirsOnly) {
ipcMain.handle('instance:worlds', (_e, id) => listDir(path.join(store.gameDir(id), 'saves'), true)); ipcMain.handle('instance:worlds', (_e, id) => listDir(path.join(store.gameDir(id), 'saves'), true));
ipcMain.handle('instance:resourcepacks', (_e, id) => listDir(path.join(store.gameDir(id), 'resourcepacks'), false)); ipcMain.handle('instance:resourcepacks', (_e, id) => listDir(path.join(store.gameDir(id), 'resourcepacks'), false));
ipcMain.handle('instance:datapacks', (_e, id) => listDir(path.join(store.gameDir(id), 'datapacks'), false));
function safeSub(id, sub, name) { function safeSub(id, sub, name) {
const base = path.join(store.gameDir(id), sub); const base = path.join(store.gameDir(id), sub);
@@ -984,6 +1033,64 @@ async function importCurseForge(buf, zip, zugang) {
}; };
} }
ipcMain.handle('modpack:search', async (_e, opts) => {
try {
const q = Object.assign({}, opts || {}, { art: 'modpack' });
return await services.modrinthSearch(q);
} catch (err) {
return { total: 0, hits: [], error: err.message };
}
});
ipcMain.handle('modpack:installFromModrinth', async (_e, project) => {
try {
const projectId = project && (project.projectId || project.id);
if (!projectId) return { ok: false, message: 'Kein Modpack gewählt.' };
const gameVersion = (project && project.gameVersion) || '';
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('launch:progress', {
phase: 'modpack', detail: 'Modpack-Version wird ermittelt …',
});
}
const file = await services.modrinthResolveModpack({ projectId, gameVersion });
if (!file) {
return {
ok: false,
message: gameVersion
? ('Keine Modpack-Version für Minecraft ' + gameVersion + ' gefunden.')
: 'Keine Modpack-Version gefunden.',
};
}
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('launch:progress', {
phase: 'modpack',
detail: 'Lade ' + (file.filename || file.name || 'Modpack') + ' …',
});
}
const buf = await services.downloadBuffer(file.url);
const zip = forge.readZipIndex(buf);
if (!zip['modrinth.index.json']) {
return { ok: false, message: 'Die geladene Datei ist kein gültiges Modrinth-Modpack (.mrpack).' };
}
const result = await importMrpack(buf, zip);
if (result && result.ok && result.instance) {
// Herkunft mit aktueller Version-ID ergänzen, falls der Index sie nicht trägt
const inst = result.instance;
const mp = Object.assign({}, inst.modpack || {}, {
projectId: projectId,
versionId: file.versionId,
versionNumber: file.versionNumber,
title: (project && project.title) || (inst.modpack && inst.modpack.title) || inst.name,
});
store.updateInstance(inst.id, { modpack: mp });
result.instance = store.getInstance(inst.id);
}
return result;
} catch (err) {
return { ok: false, message: 'Modpack-Installation fehlgeschlagen: ' + err.message };
}
});
ipcMain.handle('modpack:import', async () => { ipcMain.handle('modpack:import', async () => {
const res = await dialog.showOpenDialog(mainWindow, { const res = await dialog.showOpenDialog(mainWindow, {
title: 'Modpack importieren', title: 'Modpack importieren',
@@ -1229,7 +1336,7 @@ function kontoSpieltNichtMehr(kontoId) {
meldeKontenAenderung(); meldeKontenAenderung();
} }
ipcMain.handle('instances:launch', async (_e, id, accountId) => { ipcMain.handle('instances:launch', async (_e, id, accountId, launchOpts) => {
const inst = store.getInstance(id); const inst = store.getInstance(id);
if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' }; if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' };
if (!inst.minecraft.version) { if (!inst.minecraft.version) {
@@ -1278,6 +1385,7 @@ ipcMain.handle('instances:launch', async (_e, id, accountId) => {
auth: null, auth: null,
// fehlende Java-Fassung bei Bedarf selbst beschaffen (abschaltbar) // fehlende Java-Fassung bei Bedarf selbst beschaffen (abschaltbar)
ensureJava: settings.autoJava === false ? null : (major, onProgress) => javadl.ensureJava(major, onProgress), ensureJava: settings.autoJava === false ? null : (major, onProgress) => javadl.ensureJava(major, onProgress),
quickPlayServer: (launchOpts && launchOpts.quickPlayServer) ? String(launchOpts.quickPlayServer).trim() : '',
}; };
// eingeloggtes Microsoft-Konto verwenden (gewähltes oder aktives) -> echter Start // eingeloggtes Microsoft-Konto verwenden (gewähltes oder aktives) -> echter Start

View File

@@ -22,6 +22,7 @@ contextBridge.exposeInMainWorld('api', {
// Mods (Modrinth) // Mods (Modrinth)
modSearch: (opts) => ipcRenderer.invoke('mods:search', opts), modSearch: (opts) => ipcRenderer.invoke('mods:search', opts),
curseforgeTestKey: (apiKey) => ipcRenderer.invoke('curseforge:testKey', apiKey),
// art: 'mod' | 'resourcepack' | 'shader' // art: 'mod' | 'resourcepack' | 'shader'
modList: (id, art) => ipcRenderer.invoke('mods:list', id, art), modList: (id, art) => ipcRenderer.invoke('mods:list', id, art),
modInstall: (id, project, art) => ipcRenderer.invoke('mods:install', id, project, art), modInstall: (id, project, art) => ipcRenderer.invoke('mods:install', id, project, art),
@@ -39,6 +40,7 @@ contextBridge.exposeInMainWorld('api', {
serversList: (id) => ipcRenderer.invoke('servers:list', id), serversList: (id) => ipcRenderer.invoke('servers:list', id),
serversAdd: (id, eintrag) => ipcRenderer.invoke('servers:add', id, eintrag), serversAdd: (id, eintrag) => ipcRenderer.invoke('servers:add', id, eintrag),
serversRemove: (id, ip) => ipcRenderer.invoke('servers:remove', id, ip), serversRemove: (id, ip) => ipcRenderer.invoke('servers:remove', id, ip),
serversRename: (id, ip, neuerName) => ipcRenderer.invoke('servers:rename', id, ip, neuerName),
serversCopyTo: (zielIds, eintrag) => ipcRenderer.invoke('servers:copyTo', zielIds, eintrag), serversCopyTo: (zielIds, eintrag) => ipcRenderer.invoke('servers:copyTo', zielIds, eintrag),
logsList: (id) => ipcRenderer.invoke('logs:list', id), logsList: (id) => ipcRenderer.invoke('logs:list', id),
logsRead: (id, name) => ipcRenderer.invoke('logs:read', id, name), logsRead: (id, name) => ipcRenderer.invoke('logs:read', id, name),
@@ -64,12 +66,13 @@ contextBridge.exposeInMainWorld('api', {
// Welten & Ressourcenpakete // Welten & Ressourcenpakete
worlds: (id) => ipcRenderer.invoke('instance:worlds', id), worlds: (id) => ipcRenderer.invoke('instance:worlds', id),
resourcepacks: (id) => ipcRenderer.invoke('instance:resourcepacks', id), resourcepacks: (id) => ipcRenderer.invoke('instance:resourcepacks', id),
datapacks: (id) => ipcRenderer.invoke('instance:datapacks', id),
deleteContent: (id, sub, name) => ipcRenderer.invoke('instance:deleteContent', id, sub, name), deleteContent: (id, sub, name) => ipcRenderer.invoke('instance:deleteContent', id, sub, name),
openSubdir: (id, sub) => ipcRenderer.invoke('instance:openSubdir', id, sub), openSubdir: (id, sub) => ipcRenderer.invoke('instance:openSubdir', id, sub),
screenshots: (id) => ipcRenderer.invoke('instance:screenshots', id), screenshots: (id) => ipcRenderer.invoke('instance:screenshots', id),
screenshotData: (id, name) => ipcRenderer.invoke('instance:screenshotData', id, name), screenshotData: (id, name) => ipcRenderer.invoke('instance:screenshotData', id, name),
openScreenshot: (id, name) => ipcRenderer.invoke('instance:openScreenshot', id, name), openScreenshot: (id, name) => ipcRenderer.invoke('instance:openScreenshot', id, name),
launchInstance: (id, accountId) => ipcRenderer.invoke('instances:launch', id, accountId), launchInstance: (id, accountId, opts) => ipcRenderer.invoke('instances:launch', id, accountId, opts),
exportInstance: (id) => ipcRenderer.invoke('instances:export', id), exportInstance: (id) => ipcRenderer.invoke('instances:export', id),
createGlobalBackup: () => ipcRenderer.invoke('backup:global'), createGlobalBackup: () => ipcRenderer.invoke('backup:global'),
listGlobalBackups: () => ipcRenderer.invoke('backup:list'), listGlobalBackups: () => ipcRenderer.invoke('backup:list'),
@@ -88,6 +91,8 @@ contextBridge.exposeInMainWorld('api', {
return () => ipcRenderer.removeListener('instance:launch-request', l); return () => ipcRenderer.removeListener('instance:launch-request', l);
}, },
importModpack: () => ipcRenderer.invoke('modpack:import'), importModpack: () => ipcRenderer.invoke('modpack:import'),
modpackSearch: (opts) => ipcRenderer.invoke('modpack:search', opts),
modpackInstallFromModrinth: (project) => ipcRenderer.invoke('modpack:installFromModrinth', project),
modpackCheck: (id) => ipcRenderer.invoke('modpack:check', id), modpackCheck: (id) => ipcRenderer.invoke('modpack:check', id),
modpackUpdate: (id, versionId) => ipcRenderer.invoke('modpack:update', id, versionId), modpackUpdate: (id, versionId) => ipcRenderer.invoke('modpack:update', id, versionId),
onLaunchProgress: (cb) => { onLaunchProgress: (cb) => {

View File

@@ -620,6 +620,99 @@ async function runModpackImport() {
} }
} }
function fmtDownloads(n) {
n = Number(n) || 0;
if (n >= 1e6) return (n / 1e6).toFixed(1).replace(/\.0$/, '') + 'M';
if (n >= 1e3) return (n / 1e3).toFixed(1).replace(/\.0$/, '') + 'k';
return String(n);
}
async function searchModpacks() {
const query = el('mp-query').value.trim();
const gameVersion = el('mp-mc').value.trim();
const sort = el('mp-sort').value || 'relevance';
const box = el('mp-results');
const st = el('mp-status');
st.textContent = 'Suche …';
box.innerHTML = '<div class="mods-empty">Suche läuft …</div>';
try {
const res = await window.api.modpackSearch({
query, gameVersion, sort, limit: 30, offset: 0, art: 'modpack',
});
if (res.error) {
st.textContent = 'Fehler: ' + res.error;
box.innerHTML = '<div class="mods-empty">Suche fehlgeschlagen.</div>';
return;
}
const hits = res.hits || [];
st.textContent = hits.length
? (res.total + ' Treffer' + (gameVersion ? ' für ' + gameVersion : ''))
: 'Keine Treffer.';
box.innerHTML = '';
if (!hits.length) {
box.innerHTML = '<div class="mods-empty">Keine Modpacks gefunden.</div>';
return;
}
for (const h of hits) {
const item = document.createElement('div');
item.className = 'mod-item';
const meta = [
h.author ? ('von ' + h.author) : '',
h.downloads != null ? (fmtDownloads(h.downloads) + ' Downloads') : '',
].filter(Boolean).join(' · ');
const cats = (h.displayCategories || h.categories || []).slice(0, 4).join(', ');
item.innerHTML =
`<div class="mod-icon">${h.iconUrl ? `<img src="${escapeHtml(h.iconUrl)}" alt="" style="width:32px;height:32px;border-radius:4px;object-fit:cover" />` : '📦'}</div>
<div class="mod-info">
<div class="mod-name">${escapeHtml(h.title || h.slug || h.projectId)}</div>
<div class="bd-meta">${escapeHtml(meta)}${cats ? ' · ' + escapeHtml(cats) : ''}</div>
<div class="bd-meta" style="opacity:.85">${escapeHtml((h.description || '').slice(0, 120))}${(h.description || '').length > 120 ? '…' : ''}</div>
</div>
<div class="mod-action"></div>`;
const btn = document.createElement('button');
btn.className = 'btn btn-primary btn-mini';
btn.textContent = 'Installieren';
btn.addEventListener('click', () => installModpackFromSearch(h, btn));
item.querySelector('.mod-action').appendChild(btn);
box.appendChild(item);
}
} catch (err) {
st.textContent = 'Fehler: ' + (err.message || err);
box.innerHTML = '<div class="mods-empty">Suche fehlgeschlagen.</div>';
}
}
async function installModpackFromSearch(hit, btn) {
if (!hit || !hit.projectId) return;
const gameVersion = el('mp-mc').value.trim();
const prev = btn ? btn.textContent : '';
if (btn) { btn.disabled = true; btn.textContent = '…'; }
status('Installiere Modpack: ' + (hit.title || hit.slug || '') + ' …');
try {
const res = await window.api.modpackInstallFromModrinth({
projectId: hit.projectId,
title: hit.title || hit.slug,
gameVersion,
});
status('Bereit'); el('status-right').textContent = '';
if (res.ok) {
await reload();
hide('modal-import');
toast(`Modpack installiert: ${res.instance.name} (${res.fileCount || 0} Dateien)`);
if (currentDetailId !== res.instance.id) {
try { await selectInstance(res.instance.id); } catch { /* egal */ }
}
return;
}
toast(res.message || 'Installation fehlgeschlagen.', true);
} catch (err) {
status('Bereit'); el('status-right').textContent = '';
toast('Installation fehlgeschlagen: ' + (err.message || err), true);
} finally {
if (btn) { btn.disabled = false; btn.textContent = prev || 'Installieren'; }
}
}
async function scanMc() { async function scanMc() {
const folder = el('mc-path').value.trim(); const folder = el('mc-path').value.trim();
el('mc-status').textContent = 'Suche Launcher-Profile …'; el('mc-status').textContent = 'Suche Launcher-Profile …';
@@ -1689,14 +1782,14 @@ async function selectInstance(id) {
let launching = false; let launching = false;
async function launch(id, accountId) { async function launch(id, accountId, launchOpts) {
if (launching) return; if (launching) return;
launching = true; launching = true;
status('Vorbereiten …'); status('Vorbereiten …');
el('status-right').textContent = ''; el('status-right').textContent = '';
appendConsole('\n=== Start: ' + new Date().toLocaleTimeString('de-DE') + ' ===\n'); appendConsole('\n=== Start: ' + new Date().toLocaleTimeString('de-DE') + ' ===\n');
try { try {
const res = await window.api.launchInstance(id, accountId); const res = await window.api.launchInstance(id, accountId, launchOpts || null);
if (res.ok) { if (res.ok) {
status('Läuft (PID ' + res.pid + ')'); status('Läuft (PID ' + res.pid + ')');
toast('Minecraft gestartet.'); toast('Minecraft gestartet.');
@@ -1847,6 +1940,37 @@ async function renderServers() {
<div class="mod-action"></div>`; <div class="mod-action"></div>`;
const aktionen = item.querySelector('.mod-action'); const aktionen = item.querySelector('.mod-action');
const starten = document.createElement('button');
starten.className = 'btn btn-primary btn-mini';
starten.textContent = 'Starten';
starten.title = 'Instanz starten und direkt diesem Server beitreten (Quick Play, ab MC 1.20)';
starten.addEventListener('click', () => {
hide('modal-servers');
launch(currentDetailId, null, { quickPlayServer: s.ip });
});
aktionen.appendChild(starten);
const umbenennen = document.createElement('button');
umbenennen.className = 'btn btn-mini';
umbenennen.textContent = 'Umbenennen';
umbenennen.title = 'Anzeigenamen ändern';
umbenennen.addEventListener('click', async () => {
const aktuell = s.name || s.ip;
const eingabe = await askPrompt({
title: 'Server umbenennen',
text: 'Adresse: ' + s.ip,
label: 'Name',
value: aktuell,
okLabel: 'Speichern',
});
if (eingabe == null) return; // abgebrochen
const res = await window.api.serversRename(currentDetailId, s.ip, eingabe);
if (!res.ok) { toast(res.message || 'Umbenennen fehlgeschlagen.', true); return; }
if (!res.unveraendert) toast('Umbenannt: ' + eingabe.trim());
await renderServers();
});
aktionen.appendChild(umbenennen);
const kopieren = document.createElement('button'); const kopieren = document.createElement('button');
kopieren.className = 'btn btn-mini'; kopieren.className = 'btn btn-mini';
kopieren.textContent = 'Übertragen…'; kopieren.textContent = 'Übertragen…';
@@ -2184,34 +2308,80 @@ function openConsole() {
// ---------- Welten & Ressourcenpakete ---------- // ---------- Welten & Ressourcenpakete ----------
async function openContent() { async function openContent() {
const inst = await window.api.getInstance(currentDetailId); const inst = await window.api.getInstance(currentDetailId);
el('content-title').textContent = 'Welten & Ressourcenpakete ' + inst.name; el('content-title').textContent = 'Welten, Ressourcen- & Datenpakete ' + inst.name;
await renderContentLists(); await renderContentLists();
show('modal-content'); show('modal-content');
} }
async function renderContentLists() { async function renderContentLists() {
renderContentList('content-worlds', await window.api.worlds(currentDetailId), 'saves', '🌍'); renderContentList('content-worlds', await window.api.worlds(currentDetailId), 'saves', '🌍');
renderContentList('content-rp', await window.api.resourcepacks(currentDetailId), 'resourcepacks', '🎨'); renderContentList('content-rp', await window.api.resourcepacks(currentDetailId), 'resourcepacks', '🎨');
renderContentList('content-dp', await window.api.datapacks(currentDetailId), 'datapacks', '📜');
} }
// ---------- Screenshots ---------- // ---------- Screenshots ----------
let screenSelected = new Set();
function updateScreensSelectionUi() {
const n = screenSelected.size;
const btn = el('screens-delete-sel');
if (btn) {
btn.disabled = n === 0;
btn.textContent = n ? (n + ' löschen') : 'Auswahl löschen';
}
const hint = el('screens-sel-hint');
if (hint) hint.textContent = n ? (n + ' ausgewählt Doppelklick öffnet das Bild') : 'Klick auf Vorschaubild zum Auswählen · Doppelklick öffnet';
document.querySelectorAll('#screens-grid .screen-thumb').forEach((t) => {
const name = t.dataset.name;
const on = name && screenSelected.has(name);
t.classList.toggle('selected', !!on);
const cb = t.querySelector('.screen-check');
if (cb) cb.checked = !!on;
});
}
async function openScreens() { async function openScreens() {
const inst = await window.api.getInstance(currentDetailId); const inst = await window.api.getInstance(currentDetailId);
el('screens-title').textContent = 'Screenshots ' + inst.name; el('screens-title').textContent = 'Screenshots ' + inst.name;
const grid = el('screens-grid'); const grid = el('screens-grid');
grid.innerHTML = '<div class="screens-empty">Lade …</div>'; grid.innerHTML = '<div class="screens-empty">Lade …</div>';
screenSelected = new Set();
updateScreensSelectionUi();
show('modal-screens'); show('modal-screens');
const shots = await window.api.screenshots(currentDetailId); const shots = await window.api.screenshots(currentDetailId);
grid.innerHTML = ''; grid.innerHTML = '';
if (!shots.length) { grid.innerHTML = '<div class="screens-empty">Keine Screenshots vorhanden.</div>'; return; } if (!shots.length) {
grid.innerHTML = '<div class="screens-empty">Keine Screenshots vorhanden.</div>';
updateScreensSelectionUi();
return;
}
for (const s of shots) { for (const s of shots) {
const thumb = document.createElement('div'); const thumb = document.createElement('div');
thumb.className = 'screen-thumb'; thumb.className = 'screen-thumb';
thumb.dataset.name = s.name;
thumb.innerHTML = thumb.innerHTML =
`<img alt="" /><div class="screen-name">${escapeHtml(s.name)}</div> `<input type="checkbox" class="screen-check" title="Auswählen" />
<img alt="" /><div class="screen-name">${escapeHtml(s.name)}</div>
<button class="screen-del" title="Löschen">🗑</button>`; <button class="screen-del" title="Löschen">🗑</button>`;
const img = thumb.querySelector('img'); const img = thumb.querySelector('img');
window.api.screenshotData(currentDetailId, s.name).then((d) => { if (d) img.src = d; }); window.api.screenshotData(currentDetailId, s.name).then((d) => { if (d) img.src = d; });
img.addEventListener('click', () => window.api.openScreenshot(currentDetailId, s.name));
const toggle = () => {
if (screenSelected.has(s.name)) screenSelected.delete(s.name);
else screenSelected.add(s.name);
updateScreensSelectionUi();
};
thumb.querySelector('.screen-check').addEventListener('click', (e) => {
e.stopPropagation();
toggle();
});
thumb.addEventListener('click', (e) => {
if (e.target.closest('.screen-del') || e.target.closest('.screen-check')) return;
toggle();
});
img.addEventListener('dblclick', (e) => {
e.stopPropagation();
window.api.openScreenshot(currentDetailId, s.name);
});
thumb.querySelector('.screen-del').addEventListener('click', (e) => { thumb.querySelector('.screen-del').addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation();
askConfirm('Screenshot löschen', `${s.name}" löschen?`, '🗑 Löschen', async () => { askConfirm('Screenshot löschen', `${s.name}" löschen?`, '🗑 Löschen', async () => {
@@ -2221,6 +2391,22 @@ async function openScreens() {
}); });
grid.appendChild(thumb); grid.appendChild(thumb);
} }
updateScreensSelectionUi();
}
async function deleteSelectedScreenshots() {
const names = [...screenSelected];
if (!names.length || !currentDetailId) return;
const label = names.length === 1
? `${names[0]}" löschen?`
: `${names.length} Screenshots unwiderruflich löschen?`;
askConfirm('Screenshots löschen', label, '🗑 Löschen', async () => {
for (const name of names) {
await window.api.deleteContent(currentDetailId, 'screenshots', name);
}
toast(names.length === 1 ? 'Screenshot gelöscht.' : names.length + ' Screenshots gelöscht.');
openScreens();
});
} }
function renderContentList(boxId, items, sub, emoji) { function renderContentList(boxId, items, sub, emoji) {
@@ -2457,28 +2643,52 @@ async function loadCatalog(append) {
if (ART_BRAUCHT_LOADER[modArt] && inst.minecraft.loader === 'vanilla') return; if (ART_BRAUCHT_LOADER[modArt] && inst.minecraft.loader === 'vanilla') return;
const box = el('m-results'); const box = el('m-results');
if (!append) { modOffset = 0; box.innerHTML = '<div class="mods-empty">Lade …</div>'; el('m-more').classList.add('hidden'); } if (!append) { modOffset = 0; box.innerHTML = '<div class="mods-empty">Lade …</div>'; el('m-more').classList.add('hidden'); }
const source = (el('m-source') && el('m-source').value) || 'modrinth';
if (el('m-category')) el('m-category').disabled = source === 'curseforge' || modArt !== 'mod';
try { try {
const res = await window.api.modSearch({ const res = await window.api.modSearch({
query: el('m-query').value.trim(), query: el('m-query').value.trim(),
gameVersion: inst.minecraft.version, gameVersion: inst.minecraft.version,
loader: inst.minecraft.loader, loader: inst.minecraft.loader,
category: modArt === 'mod' ? el('m-category').value : '', category: (source === 'modrinth' && modArt === 'mod') ? el('m-category').value : '',
sort: el('m-sort').value, sort: el('m-sort').value,
limit: 30, limit: 30,
offset: modOffset, offset: modOffset,
art: modArt, art: modArt,
source,
}); });
if (!append) box.innerHTML = ''; if (!append) box.innerHTML = '';
if (!res.hits.length && !append) { if (res && (res.error || res.needKey)) {
if (!append) {
box.innerHTML = `<div class="mods-empty">${escapeHtml(res.error || 'Suche nicht möglich.')}</div>`;
if (res.needKey || /API-Key|Weiterleitung reicht/i.test(res.error || '')) {
const wrap = document.createElement('div');
wrap.className = 'mods-empty';
const btn = document.createElement('button');
btn.className = 'btn btn-mini';
btn.textContent = 'Zu den Netzwerk-Einstellungen';
btn.addEventListener('click', () => { hide('modal-mods'); openSettings('pane-network'); });
wrap.appendChild(btn);
box.appendChild(wrap);
}
}
el('m-more').classList.add('hidden');
return;
}
const hits = (res && res.hits) || [];
if (!hits.length && !append) {
box.innerHTML = '<div class="mods-empty">Nichts gefunden.</div>'; box.innerHTML = '<div class="mods-empty">Nichts gefunden.</div>';
el('m-more').classList.add('hidden'); el('m-more').classList.add('hidden');
return; return;
} }
for (const mod of res.hits) box.appendChild(buildResultItem(mod)); for (const mod of hits) box.appendChild(buildResultItem(mod));
modOffset += res.hits.length; modOffset += hits.length;
el('m-more').classList.toggle('hidden', res.hits.length < 30 || modOffset >= res.total); el('m-more').classList.toggle('hidden', hits.length < 30 || modOffset >= (res.total || 0));
} catch (e) { } catch (e) {
if (!append) box.innerHTML = `<div class="mods-empty">Fehler: ${escapeHtml(e.message)}</div>`; if (!append) {
const msg = (e && e.message) ? e.message : String(e);
box.innerHTML = `<div class="mods-empty">Fehler: ${escapeHtml(msg)}</div>`;
}
} }
} }
@@ -2502,7 +2712,7 @@ function buildResultItem(mod) {
info.innerHTML = info.innerHTML =
`<div class="mod-name">${escapeHtml(mod.title)}</div> `<div class="mod-name">${escapeHtml(mod.title)}</div>
<div class="mod-desc">${escapeHtml(mod.description || '')}</div> <div class="mod-desc">${escapeHtml(mod.description || '')}</div>
<div class="mod-meta">von ${escapeHtml(mod.author)} · ${Number(mod.downloads).toLocaleString('de-DE')} Downloads</div>`; <div class="mod-meta">von ${escapeHtml(mod.author || 'unbekannt')} · ${Number(mod.downloads).toLocaleString('de-DE')} Downloads${mod.source === 'curseforge' ? ' · CurseForge' : ''}</div>`;
const action = document.createElement('div'); const action = document.createElement('div');
action.className = 'mod-action'; action.className = 'mod-action';
@@ -3119,6 +3329,31 @@ function onAuthCode(code) {
} }
// ---------- Bestätigung ---------- // ---------- Bestätigung ----------
let promptResolver = null;
function askPrompt({ title, text, label, value, okLabel } = {}) {
return new Promise((resolve) => {
// falls schon ein Prompt offen ist
if (promptResolver) { promptResolver(null); promptResolver = null; }
promptResolver = resolve;
el('prompt-title').textContent = title || 'Eingabe';
el('prompt-text').textContent = text || '';
el('prompt-text').classList.toggle('hidden', !text);
el('prompt-label').textContent = label || 'Wert';
el('prompt-ok').textContent = okLabel || 'OK';
const input = el('prompt-input');
input.value = value != null ? String(value) : '';
show('modal-prompt');
setTimeout(() => { input.focus(); input.select(); }, 30);
});
}
function closePrompt(wert) {
hide('modal-prompt');
const r = promptResolver;
promptResolver = null;
if (r) r(wert);
}
function askConfirm(title, text, okLabel, action) { function askConfirm(title, text, okLabel, action) {
el('confirm-title').textContent = title; el('confirm-title').textContent = title;
el('confirm-text').textContent = text; el('confirm-text').textContent = text;
@@ -3189,10 +3424,20 @@ function wire() {
tab.addEventListener('click', async () => { tab.addEventListener('click', async () => {
setImportKind(tab.dataset.kind); setImportKind(tab.dataset.kind);
if (tab.dataset.kind === 'mc') await ensureDefaultMcScan(); if (tab.dataset.kind === 'mc') await ensureDefaultMcScan();
if (tab.dataset.kind === 'modpack') {
const box = el('mp-results');
if (box && !box.dataset.loaded) {
box.dataset.loaded = '1';
searchModpacks();
}
}
}); });
}); });
el('import-zip-do').addEventListener('click', runZipImport); el('import-zip-do').addEventListener('click', runZipImport);
el('import-modpack-do').addEventListener('click', runModpackImport); el('import-modpack-do').addEventListener('click', runModpackImport);
el('mp-search-btn').addEventListener('click', searchModpacks);
el('mp-query').addEventListener('keydown', (e) => { if (e.key === 'Enter') searchModpacks(); });
el('mp-mc').addEventListener('keydown', (e) => { if (e.key === 'Enter') searchModpacks(); });
el('mc-pick').addEventListener('click', async () => { el('mc-pick').addEventListener('click', async () => {
const p = await window.api.mcPickFolder(); const p = await window.api.mcPickFolder();
if (p) { el('mc-path').value = p; await scanMc(); } if (p) { el('mc-path').value = p; await scanMc(); }
@@ -3375,12 +3620,25 @@ function wire() {
el('content-open-worlds').addEventListener('click', () => window.api.openSubdir(currentDetailId, 'saves')); el('content-open-worlds').addEventListener('click', () => window.api.openSubdir(currentDetailId, 'saves'));
el('content-open-rp').addEventListener('click', () => window.api.openSubdir(currentDetailId, 'resourcepacks')); el('content-open-rp').addEventListener('click', () => window.api.openSubdir(currentDetailId, 'resourcepacks'));
el('screens-open').addEventListener('click', () => window.api.openSubdir(currentDetailId, 'screenshots')); el('screens-open').addEventListener('click', () => window.api.openSubdir(currentDetailId, 'screenshots'));
el('screens-select-all').addEventListener('click', () => {
document.querySelectorAll('#screens-grid .screen-thumb').forEach((t) => {
if (t.dataset.name) screenSelected.add(t.dataset.name);
});
updateScreensSelectionUi();
});
el('screens-select-none').addEventListener('click', () => {
screenSelected.clear();
updateScreensSelectionUi();
});
el('screens-delete-sel').addEventListener('click', () => deleteSelectedScreenshots());
el('content-open-dp').addEventListener('click', () => window.api.openSubdir(currentDetailId, 'datapacks'));
// Mod-Dialog // Mod-Dialog
el('m-search-btn').addEventListener('click', () => loadCatalog(false)); el('m-search-btn').addEventListener('click', () => loadCatalog(false));
el('m-query').addEventListener('keydown', (e) => { if (e.key === 'Enter') loadCatalog(false); }); el('m-query').addEventListener('keydown', (e) => { if (e.key === 'Enter') loadCatalog(false); });
el('m-sort').addEventListener('change', () => loadCatalog(false)); el('m-sort').addEventListener('change', () => loadCatalog(false));
el('m-category').addEventListener('change', () => loadCatalog(false)); el('m-category').addEventListener('change', () => loadCatalog(false));
if (el('m-source')) el('m-source').addEventListener('change', () => loadCatalog(false));
el('m-more').addEventListener('click', () => loadCatalog(true)); el('m-more').addEventListener('click', () => loadCatalog(true));
el('m-tab-catalog').addEventListener('click', () => switchModTab('catalog')); el('m-tab-catalog').addEventListener('click', () => switchModTab('catalog'));
el('m-tab-installed').addEventListener('click', () => switchModTab('installed')); el('m-tab-installed').addEventListener('click', () => switchModTab('installed'));
@@ -3447,6 +3705,25 @@ function wire() {
el('s-backup-global').addEventListener('click', runGlobalBackup); el('s-backup-global').addEventListener('click', runGlobalBackup);
el('s-restore-global').addEventListener('click', promptGlobalRestore); el('s-restore-global').addEventListener('click', promptGlobalRestore);
el('btn-save-settings').addEventListener('click', saveSettings); el('btn-save-settings').addEventListener('click', saveSettings);
if (el('s-cf-test')) {
el('s-cf-test').addEventListener('click', async () => {
const st = el('s-cf-test-status');
const key = el('s-cf-key').value.trim();
st.textContent = 'Prüfe bei CurseForge …';
try {
const res = await window.api.curseforgeTestKey(key);
st.textContent = (res.ok ? '✓ ' : '✖ ') + (res.message || '');
if (res.proben) {
st.textContent += ' [' + res.proben.map((p) => p.name + ':' + p.status).join(', ') + ']';
}
if (res.siehtAusWieCurseForge === false && key) {
st.textContent += ' Hinweis: Erwartet wird ein Studios-Key (cfc_pat_…) oder ein klassischer Key ($2a$10$…).';
}
} catch (err) {
st.textContent = '✖ ' + (err.message || err);
}
});
}
document.querySelectorAll('.snav').forEach((b) => b.addEventListener('click', () => setSettingsPane(b.dataset.pane))); document.querySelectorAll('.snav').forEach((b) => b.addEventListener('click', () => setSettingsPane(b.dataset.pane)));
el('s-open-accounts').addEventListener('click', () => { hide('modal-settings'); openLogin(); }); el('s-open-accounts').addEventListener('click', () => { hide('modal-settings'); openLogin(); });
el('s-add-account').addEventListener('click', () => { hide('modal-settings'); openLogin().then(addAccount); }); el('s-add-account').addEventListener('click', () => { hide('modal-settings'); openLogin().then(addAccount); });
@@ -3484,6 +3761,27 @@ function wire() {
}); });
// Bestätigung // Bestätigung
el('prompt-ok').addEventListener('click', () => {
const v = el('prompt-input').value;
closePrompt(v);
});
el('prompt-input').addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
closePrompt(el('prompt-input').value);
}
});
// Abbrechen / Schließen des Prompt-Dialogs liefert null
el('modal-prompt').querySelectorAll('[data-close]').forEach((b) => {
b.addEventListener('click', (e) => {
e.stopPropagation();
closePrompt(null);
});
});
el('modal-prompt').addEventListener('mousedown', (e) => {
if (e.target === el('modal-prompt')) closePrompt(null);
});
el('confirm-ok').addEventListener('click', async () => { el('confirm-ok').addEventListener('click', async () => {
hide('modal-confirm'); hide('modal-confirm');
if (confirmAction) { const a = confirmAction; confirmAction = null; await a(); } if (confirmAction) { const a = confirmAction; confirmAction = null; await a(); }
@@ -3491,12 +3789,22 @@ function wire() {
// generisches Schließen // generisches Schließen
document.querySelectorAll('[data-close]').forEach((b) => document.querySelectorAll('[data-close]').forEach((b) =>
b.addEventListener('click', () => b.closest('.modal').classList.add('hidden'))); b.addEventListener('click', () => {
const m = b.closest('.modal');
if (!m) return;
if (m.id === 'modal-prompt') { closePrompt(null); return; }
m.classList.add('hidden');
}));
document.querySelectorAll('.modal').forEach((m) => document.querySelectorAll('.modal').forEach((m) =>
m.addEventListener('mousedown', (e) => { if (e.target === m) m.classList.add('hidden'); })); m.addEventListener('mousedown', (e) => {
if (e.target !== m) return;
if (m.id === 'modal-prompt') { closePrompt(null); return; }
m.classList.add('hidden');
}));
document.addEventListener('keydown', (e) => { document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') { if (e.key === 'Escape') {
if (!el('ctx-menu').classList.contains('hidden')) { closeContextMenu(); return; } if (!el('ctx-menu').classList.contains('hidden')) { closeContextMenu(); return; }
if (!el('modal-prompt').classList.contains('hidden')) { closePrompt(null); return; }
const open = [...document.querySelectorAll('.modal:not(.hidden)')].pop(); const open = [...document.querySelectorAll('.modal:not(.hidden)')].pop();
if (open) open.classList.add('hidden'); if (open) open.classList.add('hidden');
} }

View File

@@ -135,6 +135,23 @@ function entferne(gameDir, ip) {
return schreibe(gameDir, liste); return schreibe(gameDir, liste);
} }
/*
* Nur den Anzeigenamen ändern die Adresse bleibt gleich.
* ip identifiziert den Eintrag (wie beim Entfernen).
*/
function benenneUm(gameDir, ip, neuerName) {
const name = String(neuerName || '').trim();
if (!name) return { ok: false, message: 'Bitte einen Namen angeben.' };
const gelesen = lies(gameDir);
if (!gelesen.ok) return gelesen;
const liste = gelesen.server;
const treffer = liste.find((s) => s.ip.toLowerCase() === String(ip || '').toLowerCase());
if (!treffer) return { ok: false, message: 'Server nicht gefunden.' };
if (treffer.name === name) return { ok: true, unveraendert: true };
treffer.name = name;
return schreibe(gameDir, liste);
}
/* /*
* Einen Server in eine andere Instanz übertragen der eigentliche Zweck: * Einen Server in eine andere Instanz übertragen der eigentliche Zweck:
* denselben Server nicht in jeder Instanz neu eintippen zu müssen. * denselben Server nicht in jeder Instanz neu eintippen zu müssen.
@@ -143,4 +160,4 @@ function uebertrage(zielGameDir, eintrag) {
return fuegeHinzu(zielGameDir, eintrag); return fuegeHinzu(zielGameDir, eintrag);
} }
module.exports = { lies, schreibe, fuegeHinzu, entferne, uebertrage, pruefeAdresse, datei }; module.exports = { lies, schreibe, fuegeHinzu, entferne, benenneUm, uebertrage, pruefeAdresse, datei };

View File

@@ -236,6 +236,7 @@ const INHALTSARTEN = {
mod: { projectType: 'mod', ordner: 'mods', label: 'Mods', loaderNoetig: true }, mod: { projectType: 'mod', ordner: 'mods', label: 'Mods', loaderNoetig: true },
resourcepack: { projectType: 'resourcepack', ordner: 'resourcepacks', label: 'Ressourcenpakete', loaderNoetig: false }, resourcepack: { projectType: 'resourcepack', ordner: 'resourcepacks', label: 'Ressourcenpakete', loaderNoetig: false },
shader: { projectType: 'shader', ordner: 'shaderpacks', label: 'Shader', loaderNoetig: false }, shader: { projectType: 'shader', ordner: 'shaderpacks', label: 'Shader', loaderNoetig: false },
modpack: { projectType: 'modpack', ordner: null, label: 'Modpacks', loaderNoetig: false },
}; };
function inhaltsArt(art) { function inhaltsArt(art) {
@@ -270,8 +271,12 @@ async function modrinthSearch({ query = '', gameVersion = '', loader = '', categ
description: h.description, description: h.description,
author: h.author, author: h.author,
downloads: h.downloads, downloads: h.downloads,
follows: h.follows || 0,
iconUrl: h.icon_url || '', iconUrl: h.icon_url || '',
categories: h.categories || [], categories: h.categories || [],
// bei Modpacks hilfreich: welche Spielversionen/Loader das Projekt angibt
versions: h.versions || [],
displayCategories: h.display_categories || h.categories || [],
})), })),
}; };
} }
@@ -302,6 +307,38 @@ async function modrinthResolveFile({ projectId, gameVersion, loader, art = 'mod'
}; };
} }
/*
* Neueste passende Modpack-Version von Modrinth.
* gameVersion optional ohne Filter kommt die neueste Version.
*/
async function modrinthResolveModpack({ projectId, gameVersion = '' } = {}) {
const url = new URL(MODRINTH + '/project/' + projectId + '/version');
if (gameVersion) url.searchParams.set('game_versions', JSON.stringify([gameVersion]));
const res = await fetch(url, { headers: { 'User-Agent': UA } });
if (!res.ok) throw new Error('Modpack-Versionen fehlgeschlagen: HTTP ' + res.status);
const versions = await res.json();
if (!Array.isArray(versions) || !versions.length) return null;
const v = versions[0];
const file = (v.files || []).find((f) => f.primary) || (v.files || [])[0];
if (!file || !file.url) return null;
return {
versionId: v.id,
versionNumber: v.version_number,
name: v.name || v.version_number,
filename: file.filename,
url: file.url,
size: file.size || 0,
gameVersions: v.game_versions || [],
loaders: v.loaders || [],
};
}
async function downloadBuffer(url, extraHeaders) {
const res = await fetch(url, { headers: Object.assign({ 'User-Agent': UA }, extraHeaders || {}) });
if (!res.ok) throw new Error('Download fehlgeschlagen: HTTP ' + res.status);
return Buffer.from(await res.arrayBuffer());
}
// extraHeaders: z. B. der CurseForge-API-Key, den das CDN seit Juli 2026 verlangt // extraHeaders: z. B. der CurseForge-API-Key, den das CDN seit Juli 2026 verlangt
async function downloadFile(url, destPath, extraHeaders) { async function downloadFile(url, destPath, extraHeaders) {
const res = await fetch(url, { headers: Object.assign({ 'User-Agent': UA }, extraHeaders || {}) }); const res = await fetch(url, { headers: Object.assign({ 'User-Agent': UA }, extraHeaders || {}) });
@@ -408,16 +445,42 @@ const STANDARD_TOKEN = String(BUILD_VORGABEN.curseforgeProxyToken || '');
* kein fremdes Kontingent. * kein fremdes Kontingent.
* -> { modus, basis, kopfzeilen, downloadAdresse(url) } oder null * -> { modus, basis, kopfzeilen, downloadAdresse(url) } oder null
*/ */
/*
* CurseForge kennt zwei Schlüsselarten:
* - Klassisch (console.curseforge.com): Header x-api-key, oft $2a$10$…
* - Studios / Profil-PAT: Header "Authorization: Key cfc_pat_…"
*/
function cfAuthHeaders(apiKey) {
let key = String(apiKey || '').trim();
if ((key.startsWith('"') && key.endsWith('"')) || (key.startsWith("'") && key.endsWith("'"))) {
key = key.slice(1, -1).trim();
}
key = key.replace(/[\r\n\t]/g, '').trim();
// Komplette Kopfzeile eingefügt?
const m = key.match(/^authorization\s*:\s*key\s+(.+)$/i);
if (m) key = m[1].trim();
if (key.toLowerCase().startsWith('key ')) key = key.slice(4).trim();
if (!key) return null;
// Neues Studios-Format
if (/^cfc_pat_/i.test(key) || /^cf[a-z]_/i.test(key)) {
return { key, headers: { Authorization: 'Key ' + key } };
}
// Klassisch
return { key, headers: { 'x-api-key': key } };
}
function curseforgeZugang(settings) { function curseforgeZugang(settings) {
const s = settings || {}; const s = settings || {};
const key = String(s.curseforgeApiKey || '').trim(); const auth = cfAuthHeaders(s.curseforgeApiKey);
if (key) { if (auth) {
return { return {
modus: 'direkt', modus: 'direkt',
basis: CURSEFORGE, basis: CURSEFORGE,
kopfzeilen: { 'x-api-key': key }, kopfzeilen: auth.headers,
downloadAdresse: (url) => url, downloadAdresse: (url) => url,
downloadKopfzeilen: { 'x-api-key': key }, downloadKopfzeilen: auth.headers,
}; };
} }
const eigeneAdresse = String(s.curseforgeProxyUrl || '').trim().replace(/\/+$/, ''); const eigeneAdresse = String(s.curseforgeProxyUrl || '').trim().replace(/\/+$/, '');
@@ -451,6 +514,250 @@ function forgeCdnUrl(fileId, fileName) {
return `https://edge.forgecdn.net/files/${s.slice(0, 4)}/${Number(s.slice(4))}/${encodeURIComponent(fileName)}`; return `https://edge.forgecdn.net/files/${s.slice(0, 4)}/${Number(s.slice(4))}/${encodeURIComponent(fileName)}`;
} }
const CF_LOADER = { forge: 1, fabric: 4, quilt: 5, neoforge: 6 };
const CF_CLASS = { mod: 6, resourcepack: 12, shader: 6552 };
function cfLoaderType(loader) {
return CF_LOADER[String(loader || '').toLowerCase()] || 0;
}
/*
* CurseForge-Katalogsuche. Braucht direkten API-Key (modus === 'direkt').
* Die Weiterleitung deckt absichtlich nur Dateiauflösung und Download ab.
*/
/*
* Prüft einen API-Key direkt gegen CurseForge (ohne den gespeicherten Stand).
* Drei Proben: games, Minecraft, Modsuche damit man sieht, ob der Key
* grundsätzlich gilt oder nur für bestimmte Bereiche fehlt.
*/
async function curseforgeTestKey(apiKey) {
const auth = cfAuthHeaders(apiKey);
if (!auth) {
return { ok: false, message: 'Kein API-Key angegeben.' };
}
const key = auth.key;
const headers = Object.assign({ Accept: 'application/json', 'User-Agent': UA }, auth.headers);
const proben = [
{ name: 'games', url: CURSEFORGE + '/games' },
{ name: 'minecraft', url: CURSEFORGE + '/games/432' },
{ name: 'suche', url: CURSEFORGE + '/mods/search?gameId=432&classId=6&pageSize=1&index=0' },
];
const ergebnisse = [];
for (const p of proben) {
try {
const res = await fetch(p.url, { headers });
let text = '';
try { text = (await res.text()).replace(/\s+/g, ' ').trim().slice(0, 120); } catch { /* egal */ }
ergebnisse.push({ name: p.name, status: res.status, ok: res.ok, text: res.ok ? 'ok' : text });
} catch (err) {
ergebnisse.push({ name: p.name, status: 0, ok: false, text: err.message });
}
}
const alleOk = ergebnisse.every((e) => e.ok);
const alle403 = ergebnisse.every((e) => e.status === 401 || e.status === 403);
let message;
if (alleOk) {
message = 'Schlüssel wird akzeptiert Katalog und Downloads sollten funktionieren.';
} else if (alle403) {
message = 'CurseForge lehnt diesen Schlüssel ab (HTTP 403/401). Entweder ist er falsch kopiert, '
+ 'wurde neu erzeugt („Regenerate“ macht den alten ungültig), oder das Organisationskonto '
+ 'ist bei CurseForge noch nicht für die API freigeschaltet.';
} else {
const suche = ergebnisse.find((e) => e.name === 'suche');
if (suche && !suche.ok && ergebnisse.filter((e) => e.name !== 'suche').every((e) => e.ok)) {
message = 'Der Schlüssel gilt grundsätzlich, aber die Modsuche wird abgelehnt. '
+ 'Für den Katalog wird der Organisationsschlüssel benötigt (nicht nur ein per-game Key).';
} else {
message = 'Gemischtes Ergebnis: ' + ergebnisse.map((e) => e.name + '=' + e.status).join(', ');
}
}
return {
ok: alleOk,
message,
laenge: key.length,
beginntMit: key.slice(0, 7),
siehtAusWieCurseForge: /^\$2[aby]\$\d{2}\$/.test(key) || /^cfc_pat_/i.test(key),
format: /^cfc_pat_/i.test(key) ? 'studios-pat' : (/^\$2[aby]\$/.test(key) ? 'klassisch' : 'unbekannt'),
proben: ergebnisse,
};
}
async function curseforgeSearch(zugang, {
query = '', gameVersion = '', loader = '', sort = 'popularity',
limit = 30, offset = 0, art = 'mod',
} = {}) {
if (!zugang || zugang.modus !== 'direkt') {
return {
total: 0,
hits: [],
needKey: true,
error: 'Für die CurseForge-Suche brauchst du einen eigenen API-Key in den Netzwerk-Einstellungen. '
+ 'Die Weiterleitung reicht für Downloads, nicht für den Katalog.',
};
}
const classId = CF_CLASS[art] || CF_CLASS.mod;
const url = new URL(CURSEFORGE + '/mods/search');
url.searchParams.set('gameId', '432');
url.searchParams.set('classId', String(classId));
if (query) url.searchParams.set('searchFilter', query);
if (gameVersion) url.searchParams.set('gameVersion', gameVersion);
const lt = cfLoaderType(loader);
// Loader-Filter nur bei Mods sinnvoll
if (art === 'mod' && lt) url.searchParams.set('modLoaderType', String(lt));
// sortField: 1=Featured 2=Popularity 3=LastUpdated 4=Name 5=Author 6=TotalDownloads
const sortMap = { popularity: 2, relevance: 2, downloads: 6, updated: 3, newest: 3, follows: 2 };
url.searchParams.set('sortField', String(sortMap[sort] || 2));
url.searchParams.set('sortOrder', 'desc');
url.searchParams.set('pageSize', String(Math.min(50, limit || 30)));
url.searchParams.set('index', String(offset || 0));
const res = await fetch(url, {
headers: Object.assign({
Accept: 'application/json',
'User-Agent': UA,
}, zugang.kopfzeilen),
});
if (!res.ok) {
let detail = '';
try { detail = (await res.text()).replace(/\s+/g, ' ').trim().slice(0, 160); } catch { /* egal */ }
if (res.status === 401 || res.status === 403) {
return {
total: 0,
hits: [],
needKey: true,
error: 'CurseForge lehnt den API-Key ab (HTTP ' + res.status + '). '
+ 'Bitte unter Optionen → Netzwerk einen gültigen Schlüssel von console.curseforge.com eintragen. '
+ 'Das Proxy-Token ist kein API-Key.'
+ (detail ? ' (' + detail + ')' : ''),
};
}
throw new Error('CurseForge-Suche fehlgeschlagen: HTTP ' + res.status + (detail ? ' ' + detail : ''));
}
const data = await res.json();
const hits = (data.data || []).map((m) => {
const author = (m.authors && m.authors[0] && m.authors[0].name) || '';
const logo = (m.logo && (m.logo.thumbnailUrl || m.logo.url)) || '';
return {
projectId: 'cf-' + m.id,
cfId: m.id,
slug: m.slug,
title: m.name,
description: m.summary || '',
author,
downloads: m.downloadCount || 0,
iconUrl: logo,
categories: (m.categories || []).map((c) => c.name).filter(Boolean),
source: 'curseforge',
};
});
const total = (data.pagination && data.pagination.totalCount) || hits.length;
return { total, hits };
}
function cfFileMatches(file, gameVersion, loader, art) {
if (!file || file.isAvailable === false) return false;
const gvs = file.gameVersions || [];
if (gameVersion && !gvs.some((v) => String(v).toLowerCase() === String(gameVersion).toLowerCase())) {
// manche Einträge trennen Version und Loader in gameVersions
const hasVer = gvs.some((v) => String(v) === gameVersion);
if (!hasVer) return false;
}
if (art === 'mod' && loader && loader !== 'vanilla') {
const want = String(loader).toLowerCase();
const ok = gvs.some((v) => String(v).toLowerCase() === want)
|| (file.sortableGameVersions || []).some((s) => {
const name = String(s.gameVersionName || s.gameVersion || '').toLowerCase();
return name === want;
});
// Wenn gar kein Loader in den Tags steht, trotzdem erlauben (manche Dateien)
const anyLoader = gvs.some((v) => /fabric|forge|quilt|neoforge/i.test(String(v)))
|| (file.sortableGameVersions || []).some((s) => /fabric|forge|quilt|neoforge/i.test(String(s.gameVersionName || '')));
if (anyLoader && !ok) return false;
}
return true;
}
async function curseforgePickFile(zugang, modId, gameVersion, loader, art) {
if (!zugang || zugang.modus !== 'direkt') {
const err = new Error('CurseForge-Dateiauswahl braucht einen eigenen API-Key.');
err.code = 'need-key';
throw err;
}
const url = new URL(CURSEFORGE + '/mods/' + modId + '/files');
url.searchParams.set('pageSize', '50');
url.searchParams.set('index', '0');
if (gameVersion) url.searchParams.set('gameVersion', gameVersion);
const lt = cfLoaderType(loader);
if (art === 'mod' && lt) url.searchParams.set('modLoaderType', String(lt));
const res = await fetch(url, {
headers: Object.assign({ Accept: 'application/json', 'User-Agent': UA }, zugang.kopfzeilen),
});
if (!res.ok) throw new Error('CurseForge-Dateien fehlgeschlagen: HTTP ' + res.status);
const data = await res.json();
const files = data.data || [];
const match = files.find((f) => cfFileMatches(f, gameVersion, loader, art)) || files[0];
if (!match) return null;
const name = match.fileName || ('file-' + match.id + '.jar');
let dl = match.downloadUrl || null;
if (!dl) dl = forgeCdnUrl(match.id, name);
return {
fileId: match.id,
filename: name,
url: dl,
versionNumber: match.displayName || match.fileName || String(match.id),
dependencies: match.dependencies || [],
};
}
async function curseforgeInstall({ zugang, projectId, title, gameVersion, loader, modsDir, art = 'mod' }) {
if (!zugang) return { ok: false, message: 'Kein CurseForge-Zugang eingerichtet.' };
const rawId = String(projectId || '').replace(/^cf-/, '');
const modId = Number(rawId);
if (!modId) return { ok: false, message: 'Ungültige CurseForge-Projekt-ID.' };
const file = await curseforgePickFile(zugang, modId, gameVersion, loader, art);
if (!file || !file.url) {
return {
ok: false,
reason: 'no-match',
message: `Keine passende Datei für ${gameVersion || '?'} / ${loader || 'any'} gefunden.`,
};
}
const dest = path.join(modsDir, file.filename);
// Download: direkt mit Key oder über Weiterleitung
const url = zugang.downloadAdresse ? zugang.downloadAdresse(file.url) : file.url;
const headers = Object.assign({}, zugang.downloadKopfzeilen || {});
const bytes = await downloadFile(url, dest, headers);
// Pflicht-Abhängigkeiten (relationType 3 = RequiredDependency)
const depIds = (file.dependencies || [])
.filter((d) => d.relationType === 3 && d.modId)
.map((d) => d.modId);
return {
ok: true,
dependencies: depIds.map((id) => ({ projectId: 'cf-' + id, required: true })),
entry: {
projectId: 'cf-' + modId,
title: title || file.filename,
filename: file.filename,
versionId: String(file.fileId),
versionNumber: file.versionNumber,
gameVersion,
loader,
source: 'curseforge',
bytes,
},
};
}
/* /*
* Löst die Dateien eines CurseForge-Modpacks auf. * Löst die Dateien eines CurseForge-Modpacks auf.
* zugang: aus curseforgeZugang(); files: [{ projectID, fileID }] * zugang: aus curseforgeZugang(); files: [{ projectID, fileID }]
@@ -504,7 +811,7 @@ async function modrinthProjectTitle(projectId) {
module.exports = { module.exports = {
scanJava, probeJava, setCacheDir, setManagedJavaDir, pruefeSpeicher, scanJava, probeJava, setCacheDir, setManagedJavaDir, pruefeSpeicher,
getMcVersions, getMcVersions,
modrinthSearch, modrinthResolveFile, modrinthInstall, modrinthProjectTitle, modrinthSearch, modrinthResolveFile, modrinthResolveModpack, modrinthInstall, modrinthProjectTitle,
modrinthFindUpdates, downloadFile, inhaltsArt, INHALTSARTEN, modrinthFindUpdates, downloadFile, downloadBuffer, inhaltsArt, INHALTSARTEN,
curseforgeResolveFiles, forgeCdnUrl, curseforgeZugang, curseforgeResolveFiles, curseforgeSearch, curseforgeInstall, curseforgeTestKey, forgeCdnUrl, curseforgeZugang,
}; };

View File

@@ -24,6 +24,7 @@ body{display:grid;grid-template-rows:auto 1fr auto;height:100vh;overflow:hidden}
--text:#e4e4e6; --dim:#9a9aa2; --text:#e4e4e6; --dim:#9a9aa2;
--sel:#0e639c; --sel-br:#1177bb; --link:#4ea6ff; --accent:#1177bb; --sel:#0e639c; --sel-br:#1177bb; --link:#4ea6ff; --accent:#1177bb;
--danger:#e06c6c; --play:#5cb85c; --danger:#e06c6c; --play:#5cb85c;
--input:#1e1e1e;
} }
:root[data-theme="dark"] body{background:var(--win)} :root[data-theme="dark"] body{background:var(--win)}
:root[data-theme="dark"] .search-wrap input, :root[data-theme="dark"] .search-wrap input,
@@ -65,7 +66,8 @@ body{display:grid;grid-template-rows:auto 1fr auto;height:100vh;overflow:hidden}
:root[data-theme="dark"] .field>span{color:var(--text)} :root[data-theme="dark"] .field>span{color:var(--text)}
:root[data-theme="dark"] #btn-console svg{color:#c8c8cc} :root[data-theme="dark"] #btn-console svg{color:#c8c8cc}
:root[data-theme="dark"] .acct-av svg{color:#c8c8cc} :root[data-theme="dark"] .acct-av svg{color:#c8c8cc}
:root[data-theme="dark"] .mods-filters select{background:#1e1e1e;color:var(--text);border-color:var(--border)} :root[data-theme="dark"] .mods-filters select,
:root[data-theme="dark"] .mods-search select{background:#1e1e1e;color:var(--text);border-color:var(--border)}
:root[data-theme="dark"] .upd-notes{background:#1e1e1e;color:var(--text)} :root[data-theme="dark"] .upd-notes{background:#1e1e1e;color:var(--text)}
:root[data-theme="dark"] .modal-close{color:#aaa} :root[data-theme="dark"] .modal-close{color:#aaa}
:root[data-theme="dark"] .modal-close:hover{color:#fff} :root[data-theme="dark"] .modal-close:hover{color:#fff}
@@ -160,6 +162,8 @@ body{display:grid;grid-template-rows:auto 1fr auto;height:100vh;overflow:hidden}
/* ---------- Modals ---------- */ /* ---------- Modals ---------- */
.modal{position:fixed;inset:0;background:rgba(0,0,0,.35);display:flex;align-items:center;justify-content:center;z-index:50} .modal{position:fixed;inset:0;background:rgba(0,0,0,.35);display:flex;align-items:center;justify-content:center;z-index:50}
/* Bestätigungsdialog immer über anderen Dialogen (z. B. Screenshots) */
#modal-confirm,#modal-prompt{z-index:120}
.modal-card{width:560px;max-width:calc(100vw - 40px);max-height:calc(100vh - 60px);background:#f6f6f6;border:1px solid #a9a9a9;border-radius:4px;box-shadow:0 10px 30px rgba(0,0,0,.3);display:flex;flex-direction:column;overflow:hidden} .modal-card{width:560px;max-width:calc(100vw - 40px);max-height:calc(100vh - 60px);background:#f6f6f6;border:1px solid #a9a9a9;border-radius:4px;box-shadow:0 10px 30px rgba(0,0,0,.3);display:flex;flex-direction:column;overflow:hidden}
.modal-sm{width:420px}.modal-lg{width:820px} .modal-sm{width:420px}.modal-lg{width:820px}
#modal-settings .modal-card{width:960px;max-width:calc(100vw - 32px)} #modal-settings .modal-card{width:960px;max-width:calc(100vw - 32px)}
@@ -224,9 +228,17 @@ body{display:grid;grid-template-rows:auto 1fr auto;height:100vh;overflow:hidden}
/* Mods */ /* Mods */
.mods-body{min-height:420px} .mods-body{min-height:420px}
.mods-warn{background:#fdecea;border:1px solid #e0b4b0;color:#a5342a;border-radius:3px;padding:10px 12px;margin-bottom:12px} .mods-warn{background:#fdecea;border:1px solid #e0b4b0;color:#a5342a;border-radius:3px;padding:10px 12px;margin-bottom:12px}
.mods-search{display:flex;gap:8px;margin-bottom:10px} .mods-search{display:flex;gap:8px;margin-bottom:10px;align-items:stretch}
.mods-search select{
flex:0 0 auto;min-width:140px;max-width:180px;
padding:7px 10px;border:1px solid var(--border);border-radius:3px;
background:var(--input,#fff);color:var(--text);
font-family:inherit;font-size:12px;
}
.mods-search select:focus{outline:none;border-color:var(--accent)}
.mods-filters{display:flex;gap:8px;margin-bottom:12px} .mods-filters{display:flex;gap:8px;margin-bottom:12px}
.mods-filters select{flex:1;padding:6px 8px;border:1px solid var(--border);border-radius:3px;background:var(--input,#fff);color:var(--text);font-family:inherit;font-size:12px} .mods-filters select{flex:1;padding:6px 8px;border:1px solid var(--border);border-radius:3px;background:var(--input,#fff);color:var(--text);font-family:inherit;font-size:12px}
.mods-filters select:focus{outline:none;border-color:var(--accent)}
#m-more{display:block;width:100%;margin-top:8px} #m-more{display:block;width:100%;margin-top:8px}
.mods-search input{flex:1;padding:7px 10px;border:1px solid var(--border);border-radius:3px;background:#fff;color:var(--text)} .mods-search input{flex:1;padding:7px 10px;border:1px solid var(--border);border-radius:3px;background:#fff;color:var(--text)}
.mods-search input:focus{outline:none;border-color:var(--accent)} .mods-search input:focus{outline:none;border-color:var(--accent)}
@@ -311,6 +323,10 @@ img.account-av{object-fit:cover;image-rendering:auto}
.screen-thumb .screen-name{font-size:11px;color:var(--dim);padding:4px 6px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .screen-thumb .screen-name{font-size:11px;color:var(--dim);padding:4px 6px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.screen-thumb .screen-del{position:absolute;top:4px;right:4px;opacity:0;background:var(--danger);color:#fff;border:none;border-radius:3px;width:24px;height:24px;cursor:pointer} .screen-thumb .screen-del{position:absolute;top:4px;right:4px;opacity:0;background:var(--danger);color:#fff;border:none;border-radius:3px;width:24px;height:24px;cursor:pointer}
.screen-thumb:hover .screen-del{opacity:1} .screen-thumb:hover .screen-del{opacity:1}
.screen-thumb.selected{outline:2px solid var(--sel-br);outline-offset:1px;background:var(--sel)}
.screen-thumb .screen-check{position:absolute;top:4px;left:4px;width:18px;height:18px;margin:0;cursor:pointer;z-index:2}
.screens-toolbar{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap}
.screens-toolbar .hint{font-size:11px}
.screens-empty{color:var(--dim);text-align:center;padding:30px;grid-column:1/-1} .screens-empty{color:var(--dim);text-align:center;padding:30px;grid-column:1/-1}
/* Konsole (dunkel wie ein Log) */ /* Konsole (dunkel wie ein Log) */