From 109842b8de0ea4d7df00fd9eee4c338fe70b025c Mon Sep 17 00:00:00 2001 From: cay Date: Tue, 14 Apr 2026 18:42:30 +0100 Subject: [PATCH] jntzjn --- app.js | 2 + public/css/gildenhalle.css | 522 +++++++++++++++++++++++++++ public/js/buildings/gildenhalle.js | 547 +++++++++++++++++++++++++++++ public/js/map-ui.js | 25 +- routes/gildenhalle.route.js | 414 ++++++++++++++++++++++ views/launcher.ejs | 1 + 6 files changed, 1505 insertions(+), 6 deletions(-) create mode 100644 public/css/gildenhalle.css create mode 100644 public/js/buildings/gildenhalle.js create mode 100644 routes/gildenhalle.route.js diff --git a/app.js b/app.js index a399f79..ec10ad8 100644 --- a/app.js +++ b/app.js @@ -33,6 +33,7 @@ const combineRoutes = require("./routes/combine.route"); const bazaarRoutes = require("./routes/bazaar.route"); const himmelstorRoutes = require("./routes/himmelstor.route"); const himmelstorDailyRoutes = require("./routes/himmelstor-daily.route"); +const gildenhalleRoutes = require("./routes/gildenhalle.route"); const compression = require("compression"); @@ -433,6 +434,7 @@ app.use("/api", combineRoutes); app.use("/api", bazaarRoutes); app.use("/himmelstor", himmelstorRoutes); app.use("/api/himmelstor/daily", himmelstorDailyRoutes); +app.use("/api", gildenhalleRoutes); /* ======================== Energie abfragen diff --git a/public/css/gildenhalle.css b/public/css/gildenhalle.css new file mode 100644 index 0000000..486123d --- /dev/null +++ b/public/css/gildenhalle.css @@ -0,0 +1,522 @@ +/* ============================================================ + public/css/gildenhalle.css +============================================================ */ + +/* ── Gilden-spezifische Farben ───────────────────────────── */ +:root { + --guild-gold: #f0d060; + --guild-border: #8b6a3c; + --guild-dark: #1a0f04; + --guild-mid: #2a1a08; + --guild-text: #f0d9a6; + --guild-muted: #a08060; + --guild-green: #4aaa30; + --guild-red: #cc3030; +} + +/* ── Mitglieder-Tabelle ─────────────────────────────────── */ +.gh-members-table { + width: 100%; + border-collapse: collapse; + font-family: "Cinzel", serif; + font-size: 12px; +} +.gh-members-table th { + color: var(--guild-muted); + font-size: 10px; + letter-spacing: 1px; + text-transform: uppercase; + padding: 6px 10px; + border-bottom: 1px solid var(--guild-border); + text-align: left; +} +.gh-members-table td { + padding: 7px 10px; + border-bottom: 1px solid rgba(139,106,42,.2); + color: var(--guild-text); + vertical-align: middle; +} +.gh-members-table tr:hover td { + background: rgba(255,255,255,.03); +} + +/* ── Rang-Badge ─────────────────────────────────────────── */ +.gh-rank-badge { + display: inline-block; + padding: 2px 8px; + border-radius: 10px; + font-size: 10px; + font-weight: bold; + background: linear-gradient(#3a2810, #1a0f04); + border: 1px solid var(--guild-border); + color: var(--guild-gold); + white-space: nowrap; +} +.gh-rank-badge.leader { + border-color: var(--guild-gold); + background: linear-gradient(#6b4b2a, #3c2414); + box-shadow: 0 0 6px rgba(200,160,60,.3); +} + +/* ── Gilden-Karten im Suchraster ────────────────────────── */ +.gh-guild-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 10px; + padding: 12px 14px; + overflow-y: auto; + align-content: start; +} + +.gh-guild-card { + background: linear-gradient(135deg, var(--guild-mid), var(--guild-dark)); + border: 2px solid var(--guild-border); + border-radius: 10px; + padding: 12px 14px; + display: flex; + flex-direction: column; + gap: 6px; + transition: .2s; + cursor: default; +} +.gh-guild-card:hover { + border-color: var(--guild-gold); + box-shadow: 0 4px 16px rgba(0,0,0,.5), 0 0 10px rgba(200,160,60,.15); +} + +.gh-guild-header { + display: flex; + align-items: center; + gap: 8px; +} +.gh-guild-tag { + background: linear-gradient(#6b4b2a, #3c2414); + border: 1px solid var(--guild-gold); + border-radius: 6px; + padding: 2px 8px; + font-family: "Cinzel", serif; + font-size: 12px; + font-weight: bold; + color: var(--guild-gold); + flex-shrink: 0; +} +.gh-guild-name { + font-family: "Cinzel", serif; + font-size: 13px; + font-weight: bold; + color: var(--guild-text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.gh-guild-desc { + font-family: "Cinzel", serif; + font-size: 10px; + color: var(--guild-muted); + line-height: 1.5; + flex: 1; +} +.gh-guild-meta { + display: flex; + justify-content: space-between; + font-family: "Cinzel", serif; + font-size: 10px; + color: var(--guild-muted); +} +.gh-guild-open { + color: var(--guild-green); + font-weight: bold; +} +.gh-guild-closed { + color: #e0a030; + font-weight: bold; +} + +.gh-join-btn { + margin-top: 4px; + padding: 5px 0; + background: linear-gradient(#3a2810, #1a0f04); + border: 2px solid var(--guild-border); + border-radius: 6px; + color: var(--guild-text); + font-family: "Cinzel", serif; + font-size: 11px; + font-weight: bold; + cursor: pointer; + transition: .15s; + width: 100%; +} +.gh-join-btn:hover:not(:disabled) { + border-color: var(--guild-gold); + color: #fff; +} +.gh-join-btn:disabled { + opacity: .45; + cursor: not-allowed; +} +.gh-join-btn.pending { + border-color: #e0a030; + color: #e0a030; +} + +/* ── Gilde gründen ──────────────────────────────────────── */ +.gh-create-form { + padding: 16px 20px; + display: flex; + flex-direction: column; + gap: 12px; + max-width: 440px; +} +.gh-form-row { + display: flex; + flex-direction: column; + gap: 4px; +} +.gh-form-label { + font-family: "Cinzel", serif; + font-size: 11px; + color: var(--guild-muted); + letter-spacing: 1px; +} +.gh-form-input, +.gh-form-textarea, +.gh-form-select { + background: rgba(0,0,0,.5); + border: 2px solid var(--guild-border); + border-radius: 7px; + color: var(--guild-text); + font-family: "Cinzel", serif; + font-size: 13px; + padding: 7px 10px; + outline: none; + transition: border-color .15s; +} +.gh-form-input:focus, +.gh-form-textarea:focus, +.gh-form-select:focus { border-color: var(--guild-gold); } +.gh-form-textarea { resize: vertical; min-height: 70px; } +.gh-form-select { cursor: pointer; } + +.gh-create-btn { + padding: 10px 0; + background: linear-gradient(#6b4b2a, #3c2414); + border: 2px solid var(--guild-gold); + border-radius: 8px; + color: #fff5cc; + font-family: "Cinzel", serif; + font-size: 13px; + font-weight: bold; + cursor: pointer; + transition: .2s; + letter-spacing: 2px; +} +.gh-create-btn:hover { + box-shadow: 0 0 14px rgba(200,160,60,.4); +} + +.gh-error-msg { + font-family: "Cinzel", serif; + font-size: 11px; + color: #ff6666; + text-align: center; +} + +/* ── Aufgaben ────────────────────────────────────────────── */ +.gh-tasks-list { + display: flex; + flex-direction: column; + gap: 10px; + padding: 12px 16px; + overflow-y: auto; +} + +.gh-task-card { + background: linear-gradient(135deg, var(--guild-mid), var(--guild-dark)); + border: 2px solid var(--guild-border); + border-radius: 10px; + padding: 12px 16px; + display: flex; + flex-direction: column; + gap: 8px; + transition: .2s; +} +.gh-task-card.completed { + border-color: var(--guild-green); + opacity: .75; +} + +.gh-task-header { + display: flex; + justify-content: space-between; + align-items: center; +} +.gh-task-label { + font-family: "Cinzel", serif; + font-size: 13px; + color: var(--guild-text); + font-weight: bold; +} +.gh-task-reward { + font-family: "Cinzel", serif; + font-size: 11px; + color: var(--guild-gold); +} +.gh-task-done-badge { + font-family: "Cinzel", serif; + font-size: 11px; + color: var(--guild-green); + font-weight: bold; +} + +.gh-task-progress-wrap { + display: flex; + flex-direction: column; + gap: 4px; +} +.gh-task-bar-track { + height: 8px; + background: rgba(255,255,255,.08); + border-radius: 4px; + overflow: hidden; + border: 1px solid rgba(139,106,42,.3); +} +.gh-task-bar-fill { + height: 100%; + background: linear-gradient(90deg, #4a8a3c, #7ada60); + border-radius: 4px; + transition: width .5s ease; +} +.gh-task-bar-fill.completed { + background: linear-gradient(90deg, var(--guild-green), #8aff60); +} +.gh-task-progress-text { + font-family: "Cinzel", serif; + font-size: 10px; + color: var(--guild-muted); + display: flex; + justify-content: space-between; +} + +/* ── Anfragen-Liste ─────────────────────────────────────── */ +.gh-requests-list { + display: flex; + flex-direction: column; + gap: 6px; + padding: 8px 0; +} +.gh-request-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + background: rgba(0,0,0,.25); + border: 1px solid rgba(139,106,42,.25); + border-radius: 7px; + gap: 10px; +} +.gh-request-name { + font-family: "Cinzel", serif; + font-size: 12px; + color: var(--guild-text); + flex: 1; +} +.gh-request-msg { + font-family: "Cinzel", serif; + font-size: 10px; + color: var(--guild-muted); + font-style: italic; + flex: 2; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.gh-req-accept, +.gh-req-reject { + padding: 4px 12px; + border-radius: 5px; + font-family: "Cinzel", serif; + font-size: 10px; + font-weight: bold; + cursor: pointer; + transition: .15s; + flex-shrink: 0; +} +.gh-req-accept { + background: linear-gradient(#1a4a18, #0f2a0e); + border: 2px solid #4a8a3c; + color: #a0e090; +} +.gh-req-accept:hover { border-color: #7ada60; } +.gh-req-reject { + background: linear-gradient(#4a1010, #2a0808); + border: 2px solid #8a3030; + color: #e07070; +} +.gh-req-reject:hover { border-color: #ff6060; } + +/* ── Meine Gilde Info-Box ───────────────────────────────── */ +.gh-my-info { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px; + background: rgba(0,0,0,.3); + border-bottom: 1px solid var(--guild-border); + flex-shrink: 0; +} +.gh-my-tag { + font-family: "Cinzel", serif; + font-size: 20px; + font-weight: bold; + color: var(--guild-gold); + background: linear-gradient(#6b4b2a, #3c2414); + border: 2px solid var(--guild-gold); + border-radius: 8px; + padding: 4px 12px; +} +.gh-my-name { + font-family: "Cinzel", serif; + font-size: 16px; + font-weight: bold; + color: var(--guild-text); +} +.gh-my-sub { + font-family: "Cinzel", serif; + font-size: 11px; + color: var(--guild-muted); +} +.gh-leave-btn { + margin-left: auto; + padding: 6px 16px; + background: linear-gradient(#3a1010, #1a0808); + border: 2px solid #6a2020; + border-radius: 7px; + color: #d08080; + font-family: "Cinzel", serif; + font-size: 11px; + cursor: pointer; + transition: .15s; +} +.gh-leave-btn:hover { border-color: #cc4040; color: #ff9090; } + +/* ── Suche ──────────────────────────────────────────────── */ +.gh-search-bar { + display: flex; + gap: 8px; + padding: 10px 14px; + border-bottom: 1px solid rgba(139,106,42,.3); + flex-shrink: 0; +} +.gh-search-input { + flex: 1; + background: rgba(0,0,0,.5); + border: 2px solid var(--guild-border); + border-radius: 7px; + color: var(--guild-text); + font-family: "Cinzel", serif; + font-size: 12px; + padding: 6px 10px; + outline: none; +} +.gh-search-input:focus { border-color: var(--guild-gold); } +.gh-search-btn { + padding: 6px 16px; + background: linear-gradient(#3a2810, #1a0f04); + border: 2px solid var(--guild-border); + border-radius: 7px; + color: var(--guild-text); + font-family: "Cinzel", serif; + font-size: 12px; + cursor: pointer; + transition: .15s; +} +.gh-search-btn:hover { border-color: var(--guild-gold); } + +/* ── Keine Gilde Hinweis ────────────────────────────────── */ +.gh-no-guild { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + gap: 12px; + font-family: "Cinzel", serif; + color: var(--guild-muted); + text-align: center; + padding: 24px; +} +.gh-no-guild-icon { font-size: 42px; } +.gh-no-guild-text { font-size: 13px; line-height: 1.8; } + +/* ── Pagination ─────────────────────────────────────────── */ +.gh-pagination { + display: flex; + align-items: center; + justify-content: center; + gap: 5px; + padding: 8px; + border-top: 1px solid rgba(139,106,42,.3); + flex-shrink: 0; +} +.gh-page-btn { + background: linear-gradient(#3a2810, #1a0f04); + border: 1px solid var(--guild-border); + border-radius: 5px; + color: var(--guild-text); + font-family: "Cinzel", serif; + font-size: 11px; + padding: 3px 10px; + cursor: pointer; + transition: .15s; +} +.gh-page-btn:hover:not(:disabled) { border-color: var(--guild-gold); } +.gh-page-btn:disabled { opacity: .35; cursor: not-allowed; } +.gh-page-btn.active { + background: linear-gradient(#6b4b2a, #3c2414); + border-color: var(--guild-gold); +} + +/* ── Lade/Leer-Zustand ──────────────────────────────────── */ +.gh-loading, .gh-empty { + text-align: center; + padding: 40px; + font-family: "Cinzel", serif; + font-size: 13px; + color: var(--guild-muted); +} + +/* ── Section-Header innerhalb Panel ────────────────────── */ +.gh-section-title { + font-family: "Cinzel", serif; + font-size: 12px; + color: var(--guild-gold); + letter-spacing: 2px; + text-transform: uppercase; + padding: 8px 16px 4px; + border-bottom: 1px solid rgba(139,106,42,.25); + flex-shrink: 0; +} + +/* ── Rang-Select in Mitglieder-Tabelle ──────────────────── */ +.gh-rank-select { + background: rgba(0,0,0,.5); + border: 1px solid var(--guild-border); + border-radius: 5px; + color: var(--guild-text); + font-family: "Cinzel", serif; + font-size: 11px; + padding: 2px 5px; + cursor: pointer; +} +.gh-kick-btn { + padding: 2px 8px; + background: linear-gradient(#4a1010, #2a0808); + border: 1px solid #8a3030; + border-radius: 5px; + color: #e07070; + font-family: "Cinzel", serif; + font-size: 10px; + cursor: pointer; + transition: .15s; +} +.gh-kick-btn:hover { border-color: #cc4040; } diff --git a/public/js/buildings/gildenhalle.js b/public/js/buildings/gildenhalle.js new file mode 100644 index 0000000..2505b60 --- /dev/null +++ b/public/js/buildings/gildenhalle.js @@ -0,0 +1,547 @@ +/* ============================================================ + public/js/buildings/gildenhalle.js +============================================================ */ + +let gh_initialized = false; +let gh_page = 1; +let gh_search = ''; +let gh_playerGuild = null; + +function ghLoadCSS() { + if (!document.querySelector('link[href="/css/gildenhalle.css"]')) { + const l = document.createElement('link'); + l.rel = 'stylesheet'; l.href = '/css/gildenhalle.css'; + document.head.appendChild(l); + } +} + +/* ── Popup sicherstellen ────────────────────────────────── */ +function ghEnsurePopup() { + if (document.getElementById('gildenhalle-popup')) return; + + const popup = document.createElement('div'); + popup.id = 'gildenhalle-popup'; + popup.className = 'qm-popup'; + popup.innerHTML = ` +
+ ⚔ Gildenhalle +
+ +
+
+
+ +
+ + +
+
Gilden
+ +
+
Lade Gilden…
+
+
+ + + +
+ + +
+
Eigene Gilde
+
+
Lade…
+
+
+ + +
+
Gilden Aufgaben
+
+
Lade Aufgaben…
+
+
+ +
+
`; + + document.body.appendChild(popup); + + popup.querySelector('#gh-close').addEventListener('click', ghClose); + + /* Tab-Wechsel */ + popup.querySelectorAll('.mp-tab').forEach(btn => { + btn.addEventListener('click', () => { + popup.querySelectorAll('.mp-tab').forEach(t => t.classList.remove('mp-tab-active')); + popup.querySelectorAll('.mp-panel').forEach(p => p.classList.remove('active')); + btn.classList.add('mp-tab-active'); + const panel = document.getElementById(btn.dataset.tab); + panel?.classList.add('active'); + + if (btn.dataset.tab === 'gh-panel-2') ghLoadMyGuild(); + if (btn.dataset.tab === 'gh-panel-3') ghLoadTasks(); + }); + }); + + /* Gründen-Toggle */ + document.getElementById('gh-create-toggle-btn').addEventListener('click', () => { + document.getElementById('gh-create-form-wrap').style.display = 'block'; + document.getElementById('gh-create-error').textContent = ''; + }); + document.getElementById('gh-create-cancel').addEventListener('click', () => { + document.getElementById('gh-create-form-wrap').style.display = 'none'; + }); + document.getElementById('gh-create-submit').addEventListener('click', ghCreateGuild); + + /* Suche */ + document.getElementById('gh-search-btn').addEventListener('click', () => { + gh_search = document.getElementById('gh-search-input').value.trim(); + gh_page = 1; + ghLoadGuildList(); + }); + document.getElementById('gh-search-input').addEventListener('keydown', e => { + if (e.key === 'Enter') document.getElementById('gh-search-btn').click(); + }); + + /* Drag */ + const header = popup.querySelector('.qm-popup-header'); + let dragging=false, sx,sy,sl,st; + header.style.cursor='grab'; + header.addEventListener('mousedown', e => { + if (e.target.classList.contains('qm-popup-close')) return; + dragging=true; header.style.cursor='grabbing'; + const r=popup.getBoundingClientRect(); + sx=e.clientX; sy=e.clientY; sl=r.left; st=r.top; + popup.style.transform='none'; + popup.style.left=sl+'px'; popup.style.top=st+'px'; + e.preventDefault(); + }); + document.addEventListener('mousemove', e => { + if (!dragging) return; + popup.style.left=(sl+(e.clientX-sx))+'px'; + popup.style.top=(st+(e.clientY-sy))+'px'; + }); + document.addEventListener('mouseup', () => { dragging=false; header.style.cursor='grab'; }); +} + +/* ════════════════════════════════════════════ + PANEL 1: Gilden-Liste laden +════════════════════════════════════════════ */ +async function ghLoadGuildList() { + const grid = document.getElementById('gh-guild-grid'); + const pagination = document.getElementById('gh-pagination'); + if (!grid) return; + grid.innerHTML = '
Lade Gilden…
'; + if (pagination) pagination.innerHTML = ''; + + try { + const res = await fetch(`/api/gildenhalle/list?search=${encodeURIComponent(gh_search)}&page=${gh_page}`); + const data = await res.json(); + gh_playerGuild = data.playerGuildId; + + if (!data.guilds.length) { + grid.innerHTML = '
Keine Gilden gefunden.
'; + return; + } + + grid.innerHTML = data.guilds.map(g => ` +
+
+ [${g.tag}] + ${g.name} +
+
${g.description || 'Keine Beschreibung'}
+
+ Lv. ${g.level} · ${g.member_count}/${g.max_members} Mitgl. + + ${g.open ? '✔ Offen' : '🔒 Anfrage'} + +
+ +
`).join(''); + + grid.querySelectorAll('.gh-join-btn:not([disabled])').forEach(btn => { + btn.addEventListener('click', () => ghJoinGuild( + parseInt(btn.dataset.id), btn.dataset.open === '1', btn + )); + }); + + ghRenderPagination(pagination, data.totalPages, data.total); + } catch (err) { + grid.innerHTML = '
Fehler beim Laden.
'; + console.error('[GH]', err); + } +} + +/* ── Gilde beitreten ────────────────────────────────────── */ +async function ghJoinGuild(guildId, isOpen, btn) { + btn.disabled = true; + btn.textContent = '…'; + + let message = null; + if (!isOpen) { + message = window.prompt('Nachricht an die Gilde (optional):') ?? ''; + } + + try { + const res = await fetch(`/api/gildenhalle/join/${guildId}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ message }), + }); + const data = await res.json(); + if (!res.ok) { alert(data.error || 'Fehler'); btn.disabled=false; btn.textContent=isOpen?'⚔ Beitreten':'✉ Anfrage stellen'; return; } + + if (data.joined) { + ghShowToast('✔ Erfolgreich der Gilde beigetreten!'); + gh_page = 1; ghLoadGuildList(); + } else { + btn.classList.add('pending'); + btn.textContent = '⏳ Anfrage ausstehend'; + ghShowToast('✉ Beitrittsanfrage gesendet!'); + } + } catch { btn.disabled=false; } +} + +/* ── Gilde gründen ──────────────────────────────────────── */ +async function ghCreateGuild() { + const name = document.getElementById('gh-new-name').value.trim(); + const tag = document.getElementById('gh-new-tag').value.trim().toUpperCase(); + const desc = document.getElementById('gh-new-desc').value.trim(); + const open = document.getElementById('gh-new-open').value; + const errEl = document.getElementById('gh-create-error'); + + errEl.textContent = ''; + if (!name || !tag) { errEl.textContent = 'Name und Tag sind Pflichtfelder.'; return; } + + const btn = document.getElementById('gh-create-submit'); + btn.disabled = true; btn.textContent = '…'; + + try { + const res = await fetch('/api/gildenhalle/create', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, tag, description: desc, open: open === '1' }), + }); + const data = await res.json(); + if (!res.ok) { errEl.textContent = data.error || 'Fehler'; btn.disabled=false; btn.textContent='⚔ GILDE GRÜNDEN'; return; } + + document.getElementById('gh-create-form-wrap').style.display = 'none'; + ghShowToast('🏰 Gilde erfolgreich gegründet!'); + gh_page = 1; ghLoadGuildList(); + + // Wechsel zu "Eigene Gilde" + setTimeout(() => { + document.querySelector('[data-tab="gh-panel-2"]')?.click(); + }, 500); + } catch { btn.disabled=false; btn.textContent='⚔ GILDE GRÜNDEN'; } +} + +/* ════════════════════════════════════════════ + PANEL 2: Eigene Gilde +════════════════════════════════════════════ */ +async function ghLoadMyGuild() { + const container = document.getElementById('gh-my-content'); + if (!container) return; + container.innerHTML = '
Lade…
'; + + try { + const res = await fetch('/api/gildenhalle/my'); + const data = await res.json(); + + if (!data.guild) { + container.innerHTML = ` +
+
🏰
+
Du bist noch in keiner Gilde.
+ Tritt einer Gilde bei oder gründe eine eigene.
+
`; + return; + } + + const { guild, members, ranks, requests } = data; + const isLeader = (guild.leader_id === /* wird per Session gesetzt */ null); + + // Mitglieder-Tabelle + const membersHtml = members.map(m => ` + + ${m.name} + Lv. ${m.level} + + ${guild.can_manage_ranks && m.id !== guild.leader_id + ? `` + : `${m.rank_name || '–'}` + } + + + ${new Date(m.joined_at).toLocaleDateString('de-DE')} + + + ${guild.can_kick && m.id !== guild.leader_id + ? `` + : ''} + + `).join(''); + + // Anfragen + const requestsHtml = requests.length > 0 + ? `
Beitrittsanfragen (${requests.length})
+
+ ${requests.map(r => ` +
+ ${r.name} + ${r.message || '–'} + ${guild.can_invite + ? ` + ` + : ''} +
`).join('')} +
` + : ''; + + container.innerHTML = ` +
+ [${guild.tag}] +
+
${guild.name}
+
Lv. ${guild.level} · ${members.length}/${guild.max_members} Mitglieder
+
+ +
+ ${requestsHtml} +
Mitglieder
+
+ + + + + + + ${membersHtml} +
NameLevelRangBeigetreten
+
`; + } catch (err) { + container.innerHTML = '
Fehler beim Laden.
'; + console.error('[GH/my]', err); + } +} + +/* ── Rang ändern ────────────────────────────────────────── */ +window.ghChangeRank = async function(userId, rankId) { + try { + const res = await fetch(`/api/gildenhalle/member/${userId}/rank`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ rank_id: rankId }), + }); + if (!res.ok) { const d=await res.json(); alert(d.error||'Fehler'); ghLoadMyGuild(); } + else ghShowToast('✔ Rang geändert'); + } catch {} +}; + +/* ── Mitglied kicken ────────────────────────────────────── */ +window.ghKickMember = async function(userId, name) { + if (!confirm(`${name} aus der Gilde entfernen?`)) return; + try { + const res = await fetch(`/api/gildenhalle/member/${userId}/kick`, { method: 'POST' }); + const d = await res.json(); + if (!res.ok) { alert(d.error||'Fehler'); return; } + ghShowToast(`✔ ${name} wurde entfernt`); + ghLoadMyGuild(); + } catch {} +}; + +/* ── Anfrage annehmen/ablehnen ──────────────────────────── */ +window.ghHandleRequest = async function(requestId, action) { + try { + const res = await fetch(`/api/gildenhalle/requests/${requestId}/${action}`, { method: 'POST' }); + const d = await res.json(); + if (!res.ok) { alert(d.error||'Fehler'); return; } + ghShowToast(action === 'accept' ? '✔ Anfrage angenommen' : '✕ Anfrage abgelehnt'); + ghLoadMyGuild(); + } catch {} +}; + +/* ── Gilde verlassen ────────────────────────────────────── */ +window.ghLeaveGuild = async function() { + if (!confirm('Wirklich die Gilde verlassen?')) return; + try { + const res = await fetch('/api/gildenhalle/leave', { method: 'POST' }); + const d = await res.json(); + if (!res.ok) { alert(d.error||'Fehler'); return; } + ghShowToast('Gilde verlassen'); + ghLoadMyGuild(); + gh_page = 1; ghLoadGuildList(); + } catch {} +}; + +/* ════════════════════════════════════════════ + PANEL 3: Aufgaben +════════════════════════════════════════════ */ +async function ghLoadTasks() { + const container = document.getElementById('gh-tasks-content'); + if (!container) return; + container.innerHTML = '
Lade Aufgaben…
'; + + try { + const res = await fetch('/api/gildenhalle/tasks'); + if (res.status === 400) { + container.innerHTML = ` +
+
📋
+
Tritt einer Gilde bei um gemeinsame Aufgaben zu sehen.
+
`; + return; + } + const data = await res.json(); + + if (!data.tasks?.length) { + container.innerHTML = '
Keine Aufgaben für heute.
'; + return; + } + + const rewardIcons = { gold: '🪙', gems: '💎', wood: '🪵', iron: '⚙️' }; + + container.innerHTML = ` +
+ Gemeinsame Aufgaben der Gilde ${data.guildName} +
+
+ ${data.tasks.map(t => { + const pct = Math.min(100, Math.round((t.current_amount / t.target_amount) * 100)); + const done = t.completed || t.current_amount >= t.target_amount; + const icon = rewardIcons[t.reward_type] || '🪙'; + return ` +
+
+ ${t.label} + ${done + ? `✔ Erledigt` + : `${icon} ${t.reward_amount} ${t.reward_type}`} +
+
+
+
+
+
+ ${t.current_amount} / ${t.target_amount} + ${pct}% · Mein Beitrag: ${t.my_contribution || 0} +
+
+
`; + }).join('')} +
`; + } catch (err) { + container.innerHTML = '
Fehler beim Laden.
'; + console.error('[GH/tasks]', err); + } +} + +/* ── Pagination ─────────────────────────────────────────── */ +function ghRenderPagination(el, totalPages, total) { + if (!el || !totalPages || totalPages <= 1) return; + el.innerHTML = ` + + ${Array.from({length:totalPages},(_,i)=>i+1).map(p => + `` + ).join('')} + `; + el.querySelector('#gh-prev')?.addEventListener('click', () => { if(gh_page>1){gh_page--;ghLoadGuildList();} }); + el.querySelector('#gh-next')?.addEventListener('click', () => { if(gh_page + btn.addEventListener('click', () => { gh_page=parseInt(btn.dataset.page); ghLoadGuildList(); }) + ); +} + +/* ── Toast ──────────────────────────────────────────────── */ +function ghShowToast(msg) { + const t = document.createElement('div'); + t.className = 'baz-toast'; t.textContent = msg; + document.body.appendChild(t); + setTimeout(() => t.remove(), 2800); +} + +/* ── Schließen ──────────────────────────────────────────── */ +function ghClose() { + document.getElementById('gildenhalle-popup')?.classList.remove('active'); + document.getElementById('qm-overlay')?.classList.remove('active'); +} + +/* ════════════════════════════════════════════ + EXPORT +════════════════════════════════════════════ */ +export function loadGildenhalle() { + ghLoadCSS(); + ghEnsurePopup(); + + const popup = document.getElementById('gildenhalle-popup'); + const overlay = document.getElementById('qm-overlay'); + popup.style.left = '50%'; popup.style.top = '50%'; + popup.style.transform = 'translate(-50%,-50%) scale(1)'; + popup.classList.add('active'); + overlay?.classList.add('active'); + + if (!gh_initialized) { + gh_initialized = true; + ghLoadGuildList(); + } +} diff --git a/public/js/map-ui.js b/public/js/map-ui.js index 3fb4833..0e4c046 100644 --- a/public/js/map-ui.js +++ b/public/js/map-ui.js @@ -5,6 +5,8 @@ import { loadBlackmarket } from "./buildings/blackmarket.js?v=2"; import { loadMine } from "./buildings/mine.js?v=2"; import { loadGlucksspiel } from "./gaststaette/glucksspiel.js"; import { loadBazaar } from "./buildings/bazaar.js"; +import { loadGildenhalle } from "./buildings/gildenhalle.js"; + const popup = document.getElementById("building-popup"); const title = document.getElementById("popup-title"); const tooltip = document.getElementById("map-tooltip"); @@ -16,12 +18,16 @@ const tooltipCache = {}; ================================ */ const gaststaettePopup = document.getElementById("gaststaette-popup"); -const qmOverlay = document.getElementById("qm-overlay"); +const qmOverlay = document.getElementById("qm-overlay"); document.querySelectorAll(".gaststaette-tab").forEach((tab) => { tab.addEventListener("click", () => { - document.querySelectorAll(".gaststaette-tab").forEach((t) => t.classList.remove("active")); - document.querySelectorAll(".gaststaette-tab-content").forEach((c) => c.classList.remove("active")); + document + .querySelectorAll(".gaststaette-tab") + .forEach((t) => t.classList.remove("active")); + document + .querySelectorAll(".gaststaette-tab-content") + .forEach((c) => c.classList.remove("active")); tab.classList.add("active"); document.getElementById(tab.dataset.tab).classList.add("active"); if (tab.dataset.tab === "gs-tab1") loadGlucksspiel(); @@ -49,7 +55,7 @@ document.getElementById("qm-overlay")?.addEventListener("mouseenter", () => { }); const buildingModules = { - 1: loadArena, // Arena – eigenes UI, Tabs ausblenden + 1: loadArena, // Arena – eigenes UI, Tabs ausblenden 3: loadHimmelstor, // Himmelstor – eigenes UI, Tabs ausblenden 10: loadMine, // Mine – Tabs bleiben sichtbar 11: loadCharacterHouse, // Charakterhaus – eigenes UI, Tabs ausblenden @@ -182,7 +188,7 @@ document.querySelectorAll(".building").forEach((building) => { // Gebäude 6 → Gaststätte eigenes Popup if (url === "/building/6") { gaststaettePopup.style.left = "50%"; - gaststaettePopup.style.top = "50%"; + gaststaettePopup.style.top = "50%"; gaststaettePopup.style.transform = "translate(-50%, -50%) scale(1)"; gaststaettePopup.classList.add("active"); qmOverlay.classList.add("active"); @@ -196,6 +202,12 @@ document.querySelectorAll(".building").forEach((building) => { return; } + // Gebäude 9 → Gildenhalle eigenes Popup + if (url === "/building/9") { + loadGildenhalle(); + return; + } + await openBuildingPopup(url); }); }); @@ -287,7 +299,8 @@ document.querySelectorAll(".building").forEach((building) => { building.addEventListener("mouseenter", async (e) => { try { // data-id bevorzugen, sonst ID aus href extrahieren (z.B. "/building/5" → "5") - const id = building.dataset.id || building.getAttribute("href")?.split("/").pop(); + const id = + building.dataset.id || building.getAttribute("href")?.split("/").pop(); if (!id) return; if (!tooltipCache[id]) { diff --git a/routes/gildenhalle.route.js b/routes/gildenhalle.route.js new file mode 100644 index 0000000..f69fe9e --- /dev/null +++ b/routes/gildenhalle.route.js @@ -0,0 +1,414 @@ +/* ============================================================ + routes/gildenhalle.route.js +============================================================ */ +'use strict'; + +const express = require('express'); +const router = express.Router(); +const db = require('../database/database'); + +function requireLogin(req, res, next) { + if (!req.session?.user) return res.status(401).json({ error: 'Nicht eingeloggt' }); + next(); +} + +/* ── Hilfsfunktion: Spieler-Gilde laden ────────────────── */ +async function getPlayerGuild(userId) { + const [[row]] = await db.query( + `SELECT g.*, gm.rank_id, gr.name AS rank_name, gr.sort_order, + gr.can_invite, gr.can_kick, gr.can_manage_ranks, gr.can_manage_tasks + FROM guild_members gm + JOIN guilds g ON g.id = gm.guild_id + LEFT JOIN guild_ranks gr ON gr.id = gm.rank_id + WHERE gm.user_id = ?`, + [userId] + ); + return row || null; +} + +/* ── Standard-Ränge für neue Gilde anlegen ─────────────── */ +async function createDefaultRanks(guildId) { + const ranks = [ + { name: 'Gildenmeister', sort_order: 1, can_invite: 1, can_kick: 1, can_manage_ranks: 1, can_manage_tasks: 1 }, + { name: 'Offizier', sort_order: 2, can_invite: 1, can_kick: 1, can_manage_ranks: 0, can_manage_tasks: 1 }, + { name: 'Veteran', sort_order: 3, can_invite: 1, can_kick: 0, can_manage_ranks: 0, can_manage_tasks: 0 }, + { name: 'Mitglied', sort_order: 99, can_invite: 0, can_kick: 0, can_manage_ranks: 0, can_manage_tasks: 0 }, + ]; + const ids = []; + for (const r of ranks) { + const [res] = await db.query( + `INSERT INTO guild_ranks (guild_id, name, sort_order, can_invite, can_kick, can_manage_ranks, can_manage_tasks) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + [guildId, r.name, r.sort_order, r.can_invite, r.can_kick, r.can_manage_ranks, r.can_manage_tasks] + ); + ids.push({ name: r.name, id: res.insertId }); + } + return ids; +} + +/* ── Tagesaufgaben für Gilde generieren ────────────────── */ +async function ensureDailyTasks(guildId) { + const today = new Date().toISOString().slice(0, 10); + const [existing] = await db.query( + 'SELECT id FROM guild_tasks WHERE guild_id = ? AND expires_at = ?', + [guildId, today] + ); + if (existing.length > 0) return; + + const [templates] = await db.query( + 'SELECT * FROM guild_task_templates WHERE active = 1' + ); + for (const t of templates) { + await db.query( + `INSERT IGNORE INTO guild_tasks + (guild_id, task_key, label, target_amount, reward_type, reward_amount, expires_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + [guildId, t.task_key, t.label, t.target_amount, t.reward_type, t.reward_amount, today] + ); + } +} + +/* ════════════════════════════════════════════ + GET /api/gildenhalle/list?search=&page=1 + Alle Gilden auflisten +════════════════════════════════════════════ */ +router.get('/gildenhalle/list', requireLogin, async (req, res) => { + const search = req.query.search?.trim() || ''; + const page = Math.max(1, parseInt(req.query.page) || 1); + const limit = 12; + const offset = (page - 1) * limit; + const userId = req.session.user.id; + + try { + const searchParam = search ? `%${search}%` : '%'; + const [guilds] = await db.query( + `SELECT g.id, g.name, g.tag, g.description, g.level, g.open, g.max_members, + COUNT(gm.id) AS member_count, + a.ingame_name AS leader_name + FROM guilds g + LEFT JOIN guild_members gm ON gm.guild_id = g.id + LEFT JOIN accounts a ON a.id = g.leader_id + WHERE g.name LIKE ? OR g.tag LIKE ? + GROUP BY g.id + ORDER BY g.level DESC, member_count DESC + LIMIT ? OFFSET ?`, + [searchParam, searchParam, limit, offset] + ); + + const [[{ total }]] = await db.query( + 'SELECT COUNT(*) AS total FROM guilds WHERE name LIKE ? OR tag LIKE ?', + [searchParam, searchParam] + ); + + // Offene Anfrage des Spielers prüfen + const [myRequests] = await db.query( + "SELECT guild_id FROM guild_join_requests WHERE user_id = ? AND status = 'pending'", + [userId] + ); + const pendingIds = new Set(myRequests.map(r => r.guild_id)); + + const playerGuild = await getPlayerGuild(userId); + + res.json({ + guilds: guilds.map(g => ({ + ...g, + has_pending_request: pendingIds.has(g.id), + })), + total, + totalPages: Math.ceil(total / limit), + page, + playerGuildId: playerGuild?.id || null, + }); + } catch (err) { + console.error('[gildenhalle/list]', err); + res.status(500).json({ error: 'DB Fehler' }); + } +}); + +/* ════════════════════════════════════════════ + POST /api/gildenhalle/create + Neue Gilde gründen +════════════════════════════════════════════ */ +router.post('/gildenhalle/create', requireLogin, async (req, res) => { + const userId = req.session.user.id; + const { name, tag, description, open } = req.body; + + if (!name?.trim() || !tag?.trim()) + return res.status(400).json({ error: 'Name und Tag sind Pflichtfelder.' }); + if (tag.length < 2 || tag.length > 6) + return res.status(400).json({ error: 'Tag muss 2–6 Zeichen lang sein.' }); + + try { + const existing = await getPlayerGuild(userId); + if (existing) return res.status(400).json({ error: 'Du bist bereits in einer Gilde.' }); + + const [result] = await db.query( + `INSERT INTO guilds (name, tag, description, leader_id, open) + VALUES (?, ?, ?, ?, ?)`, + [name.trim(), tag.trim().toUpperCase(), description?.trim() || null, userId, open ? 1 : 0] + ); + const guildId = result.insertId; + + // Standard-Ränge anlegen + const rankIds = await createDefaultRanks(guildId); + const leaderRankId = rankIds.find(r => r.name === 'Gildenmeister')?.id; + + // Gründer als Mitglied mit höchstem Rang eintragen + await db.query( + 'INSERT INTO guild_members (guild_id, user_id, rank_id) VALUES (?, ?, ?)', + [guildId, userId, leaderRankId] + ); + + // Erste Tagesaufgaben generieren + await ensureDailyTasks(guildId); + + res.json({ success: true, guildId }); + } catch (err) { + if (err.code === 'ER_DUP_ENTRY') + return res.status(400).json({ error: 'Gildenname oder Tag bereits vergeben.' }); + console.error('[gildenhalle/create]', err); + res.status(500).json({ error: 'DB Fehler' }); + } +}); + +/* ════════════════════════════════════════════ + POST /api/gildenhalle/join/:id + Gilde beitreten oder Anfrage stellen +════════════════════════════════════════════ */ +router.post('/gildenhalle/join/:id', requireLogin, async (req, res) => { + const userId = req.session.user.id; + const guildId = parseInt(req.params.id); + + try { + const existing = await getPlayerGuild(userId); + if (existing) return res.status(400).json({ error: 'Du bist bereits in einer Gilde.' }); + + const [[guild]] = await db.query('SELECT * FROM guilds WHERE id = ?', [guildId]); + if (!guild) return res.status(404).json({ error: 'Gilde nicht gefunden.' }); + + const [[memberCount]] = await db.query( + 'SELECT COUNT(*) AS c FROM guild_members WHERE guild_id = ?', [guildId] + ); + if (memberCount.c >= guild.max_members) + return res.status(400).json({ error: 'Gilde ist voll.' }); + + if (guild.open) { + // Standard-Rang (Mitglied = höchste sort_order) + const [[defaultRank]] = await db.query( + 'SELECT id FROM guild_ranks WHERE guild_id = ? ORDER BY sort_order DESC LIMIT 1', + [guildId] + ); + await db.query( + 'INSERT INTO guild_members (guild_id, user_id, rank_id) VALUES (?, ?, ?)', + [guildId, userId, defaultRank?.id || null] + ); + res.json({ success: true, joined: true }); + } else { + // Anfrage stellen + await db.query( + `INSERT IGNORE INTO guild_join_requests (guild_id, user_id, message) + VALUES (?, ?, ?)`, + [guildId, userId, req.body.message?.trim() || null] + ); + res.json({ success: true, joined: false, requested: true }); + } + } catch (err) { + if (err.code === 'ER_DUP_ENTRY') + return res.status(400).json({ error: 'Anfrage bereits gestellt.' }); + console.error('[gildenhalle/join]', err); + res.status(500).json({ error: 'DB Fehler' }); + } +}); + +/* ════════════════════════════════════════════ + POST /api/gildenhalle/leave + Gilde verlassen +════════════════════════════════════════════ */ +router.post('/gildenhalle/leave', requireLogin, async (req, res) => { + const userId = req.session.user.id; + try { + const guild = await getPlayerGuild(userId); + if (!guild) return res.status(400).json({ error: 'Du bist in keiner Gilde.' }); + if (guild.leader_id === userId) + return res.status(400).json({ error: 'Als Gildenleiterin kannst du nicht austreten. Übertrage zuerst die Leitung.' }); + + await db.query('DELETE FROM guild_members WHERE user_id = ? AND guild_id = ?', [userId, guild.id]); + res.json({ success: true }); + } catch (err) { + console.error('[gildenhalle/leave]', err); + res.status(500).json({ error: 'DB Fehler' }); + } +}); + +/* ════════════════════════════════════════════ + GET /api/gildenhalle/my + Eigene Gilde + Mitglieder laden +════════════════════════════════════════════ */ +router.get('/gildenhalle/my', requireLogin, async (req, res) => { + const userId = req.session.user.id; + try { + const guild = await getPlayerGuild(userId); + if (!guild) return res.json({ guild: null }); + + await ensureDailyTasks(guild.id); + + const [members] = await db.query( + `SELECT a.id, a.ingame_name AS name, a.level, + gr.id AS rank_id, gr.name AS rank_name, gr.sort_order, + gm.joined_at + FROM guild_members gm + JOIN accounts a ON a.id = gm.user_id + LEFT JOIN guild_ranks gr ON gr.id = gm.rank_id + WHERE gm.guild_id = ? + ORDER BY gr.sort_order ASC, gm.joined_at ASC`, + [guild.id] + ); + + const [ranks] = await db.query( + 'SELECT * FROM guild_ranks WHERE guild_id = ? ORDER BY sort_order ASC', + [guild.id] + ); + + const [requests] = await db.query( + `SELECT gjr.id, gjr.user_id, a.ingame_name AS name, gjr.message, gjr.created_at + FROM guild_join_requests gjr + JOIN accounts a ON a.id = gjr.user_id + WHERE gjr.guild_id = ? AND gjr.status = 'pending'`, + [guild.id] + ); + + res.json({ guild, members, ranks, requests }); + } catch (err) { + console.error('[gildenhalle/my]', err); + res.status(500).json({ error: 'DB Fehler' }); + } +}); + +/* ════════════════════════════════════════════ + GET /api/gildenhalle/tasks + Gilden-Tagesaufgaben laden +════════════════════════════════════════════ */ +router.get('/gildenhalle/tasks', requireLogin, async (req, res) => { + const userId = req.session.user.id; + try { + const guild = await getPlayerGuild(userId); + if (!guild) return res.status(400).json({ error: 'Du bist in keiner Gilde.' }); + + await ensureDailyTasks(guild.id); + const today = new Date().toISOString().slice(0, 10); + + const [tasks] = await db.query( + `SELECT t.*, + COALESCE(SUM(c.amount), 0) AS contributed_total, + (SELECT COALESCE(SUM(c2.amount),0) FROM guild_task_contributions c2 + WHERE c2.task_id = t.id AND c2.user_id = ?) AS my_contribution + FROM guild_tasks t + LEFT JOIN guild_task_contributions c ON c.task_id = t.id + WHERE t.guild_id = ? AND t.expires_at = ? + GROUP BY t.id`, + [userId, guild.id, today] + ); + + res.json({ tasks, guildName: guild.name }); + } catch (err) { + console.error('[gildenhalle/tasks]', err); + res.status(500).json({ error: 'DB Fehler' }); + } +}); + +/* ════════════════════════════════════════════ + POST /api/gildenhalle/requests/:id/accept + POST /api/gildenhalle/requests/:id/reject + Beitrittsanfragen verwalten +════════════════════════════════════════════ */ +router.post('/gildenhalle/requests/:id/:action', requireLogin, async (req, res) => { + const userId = req.session.user.id; + const requestId = parseInt(req.params.id); + const action = req.params.action; // accept | reject + + if (!['accept', 'reject'].includes(action)) + return res.status(400).json({ error: 'Ungültige Aktion.' }); + + try { + const guild = await getPlayerGuild(userId); + if (!guild?.can_invite) return res.status(403).json({ error: 'Keine Berechtigung.' }); + + const [[request]] = await db.query( + "SELECT * FROM guild_join_requests WHERE id = ? AND guild_id = ? AND status = 'pending'", + [requestId, guild.id] + ); + if (!request) return res.status(404).json({ error: 'Anfrage nicht gefunden.' }); + + await db.query( + 'UPDATE guild_join_requests SET status = ? WHERE id = ?', + [action === 'accept' ? 'accepted' : 'rejected', requestId] + ); + + if (action === 'accept') { + const [[defaultRank]] = await db.query( + 'SELECT id FROM guild_ranks WHERE guild_id = ? ORDER BY sort_order DESC LIMIT 1', + [guild.id] + ); + await db.query( + 'INSERT IGNORE INTO guild_members (guild_id, user_id, rank_id) VALUES (?, ?, ?)', + [guild.id, request.user_id, defaultRank?.id || null] + ); + } + + res.json({ success: true }); + } catch (err) { + console.error('[gildenhalle/requests]', err); + res.status(500).json({ error: 'DB Fehler' }); + } +}); + +/* ════════════════════════════════════════════ + POST /api/gildenhalle/member/:userId/rank + Rang eines Mitglieds ändern +════════════════════════════════════════════ */ +router.post('/gildenhalle/member/:targetId/rank', requireLogin, async (req, res) => { + const userId = req.session.user.id; + const targetId = parseInt(req.params.targetId); + const { rank_id } = req.body; + + try { + const guild = await getPlayerGuild(userId); + if (!guild?.can_manage_ranks) return res.status(403).json({ error: 'Keine Berechtigung.' }); + if (targetId === guild.leader_id) return res.status(403).json({ error: 'Rang des Gildenmeisters kann nicht geändert werden.' }); + + await db.query( + 'UPDATE guild_members SET rank_id = ? WHERE user_id = ? AND guild_id = ?', + [rank_id, targetId, guild.id] + ); + res.json({ success: true }); + } catch (err) { + console.error('[gildenhalle/member/rank]', err); + res.status(500).json({ error: 'DB Fehler' }); + } +}); + +/* ════════════════════════════════════════════ + POST /api/gildenhalle/member/:userId/kick + Mitglied entfernen +════════════════════════════════════════════ */ +router.post('/gildenhalle/member/:targetId/kick', requireLogin, async (req, res) => { + const userId = req.session.user.id; + const targetId = parseInt(req.params.targetId); + + try { + const guild = await getPlayerGuild(userId); + if (!guild?.can_kick) return res.status(403).json({ error: 'Keine Berechtigung.' }); + if (targetId === guild.leader_id) return res.status(403).json({ error: 'Gildenmeister kann nicht gekickt werden.' }); + + await db.query( + 'DELETE FROM guild_members WHERE user_id = ? AND guild_id = ?', + [targetId, guild.id] + ); + res.json({ success: true }); + } catch (err) { + console.error('[gildenhalle/member/kick]', err); + res.status(500).json({ error: 'DB Fehler' }); + } +}); + +module.exports = router; diff --git a/views/launcher.ejs b/views/launcher.ejs index 91e0dad..89c6035 100644 --- a/views/launcher.ejs +++ b/views/launcher.ejs @@ -97,6 +97,7 @@ +