diff --git a/frontend/src/components/EmojiPick.jsx b/frontend/src/components/EmojiPick.jsx
index 187930a..fa2115b 100644
--- a/frontend/src/components/EmojiPick.jsx
+++ b/frontend/src/components/EmojiPick.jsx
@@ -9,7 +9,8 @@
// Suchbegriffe dürfen deutsch sein.
//
// Server-Emojis (<:name:id>) tippt man weiter ins Feld — die kennt nur Discord.
-import { useEffect, useRef, useState } from 'react';
+import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
+import { createPortal } from 'react-dom';
import { IconSearch, IconX } from '../icons.jsx';
const GRUPPEN = [
@@ -75,21 +76,50 @@ const GRUPPEN = [
export default function EmojiPick({ value, onChange, placeholder = '🙂' }) {
const [offen, setOffen] = useState(false);
const [suche, setSuche] = useState('');
+ const [platz, setPlatz] = useState(null);
const wrap = useRef(null);
+ const tafel = useRef(null);
+
+ // Die Tafel hängt an
, nicht am Feld: der Config-Bereich läuft
+ // stellenweise zweispaltig (CSS columns), und darin zerreißt es absolut
+ // positionierte Kästen — die Tafel landete dann in der anderen Spalte.
+ const messen = useCallback(() => {
+ const r = wrap.current?.getBoundingClientRect();
+ if (!r) return;
+ const breite = Math.min(336, window.innerWidth - 24);
+ const hoehe = 340;
+ // Nach oben klappen, wenn unten kein Platz ist
+ const nachOben = r.bottom + hoehe > window.innerHeight && r.top > hoehe;
+ setPlatz({
+ left: Math.max(12, Math.min(r.left, window.innerWidth - breite - 12)),
+ top: nachOben ? Math.max(12, r.top - hoehe - 6) : r.bottom + 6,
+ breite,
+ });
+ }, []);
+
+ useLayoutEffect(() => { if (offen) messen(); }, [offen, messen]);
// Klick daneben oder Escape schließt — ohne das bleibt das Feld offen
// stehen, sobald man woanders weiterarbeitet
useEffect(() => {
if (!offen) return undefined;
- const aus = (e) => { if (!wrap.current?.contains(e.target)) setOffen(false); };
+ const aus = (e) => {
+ if (wrap.current?.contains(e.target) || tafel.current?.contains(e.target)) return;
+ setOffen(false);
+ };
const taste = (e) => { if (e.key === 'Escape') setOffen(false); };
document.addEventListener('mousedown', aus);
document.addEventListener('keydown', taste);
+ window.addEventListener('resize', messen);
+ // Beim Scrollen mitwandern — capture, damit auch innere Bereiche zählen
+ window.addEventListener('scroll', messen, true);
return () => {
document.removeEventListener('mousedown', aus);
document.removeEventListener('keydown', taste);
+ window.removeEventListener('resize', messen);
+ window.removeEventListener('scroll', messen, true);
};
- }, [offen]);
+ }, [offen, messen]);
const q = suche.trim().toLowerCase();
const gefiltert = GRUPPEN
@@ -127,8 +157,12 @@ export default function EmojiPick({ value, onChange, placeholder = '🙂' }) {
)}
- {offen && (
-
+ {offen && platz && createPortal(
+
+
,
+ document.body
)}
);
diff --git a/frontend/src/components/UptimeTrack.jsx b/frontend/src/components/UptimeTrack.jsx
index 733eb29..a66215f 100644
--- a/frontend/src/components/UptimeTrack.jsx
+++ b/frontend/src/components/UptimeTrack.jsx
@@ -46,6 +46,17 @@ export default function UptimeTrack({ buckets, modus = 'stunden' }) {
}
const quote = gemessen > 0 ? (erreichbar / gemessen) * 100 : null;
+
+ // Wer seit einem Tag misst, hat sechs Siebtel graue Striche — das sieht aus
+ // wie ein kaputter Balken, ist aber die Wahrheit. Also links nicht stur
+ // „vor 7 Tagen" behaupten, sondern sagen, ab wann überhaupt gemessen wurde.
+ const ersteMessung = striche.find((s) => s.b)?.zeit ?? null;
+ const frueh = striche[0]?.zeit;
+ const spaeterBegonnen = ersteMessung && frueh
+ && ersteMessung.getTime() - frueh.getTime() > m.ms * 2;
+ const linkeBeschriftung = spaeterBegonnen
+ ? t('server.measuredSince', datum(ersteMessung, { day: '2-digit', month: '2-digit' }))
+ : t(modus === 'tage' ? 'status.days90' : 'server.days7');
const titel = (s) => {
const wann = modus === 'tage'
? datum(s.zeit, { day: '2-digit', month: '2-digit', year: 'numeric' })
@@ -66,7 +77,7 @@ export default function UptimeTrack({ buckets, modus = 'stunden' }) {
))}
-
{modus === 'tage' ? t('status.days90') : t('server.days7')}
+
{linkeBeschriftung}
{quote != null && (
= 99 ? 'up-good' : quote >= 90 ? '' : 'up-bad'}>
{quote.toLocaleString(tag, { minimumFractionDigits: 1, maximumFractionDigits: 1 })} %
diff --git a/frontend/src/locales/de.js b/frontend/src/locales/de.js
index 4edaee6..afea091 100644
--- a/frontend/src/locales/de.js
+++ b/frontend/src/locales/de.js
@@ -158,6 +158,7 @@ export default {
'server.players24h': 'Spielerzahl · letzte 24h',
'server.peak': 'Peak',
'server.uptime': 'Uptime',
+ 'server.measuredSince': 'misst seit %s',
'server.days7': 'vor 7 Tagen',
'server.now': 'jetzt',
'server.noData': 'keine Messung',
diff --git a/frontend/src/locales/en.js b/frontend/src/locales/en.js
index b1d5eb1..5075949 100644
--- a/frontend/src/locales/en.js
+++ b/frontend/src/locales/en.js
@@ -157,6 +157,7 @@ export default {
'server.players24h': 'Player count · last 24h',
'server.peak': 'Peak',
'server.uptime': 'Uptime',
+ 'server.measuredSince': 'measuring since %s',
'server.days7': '7 days ago',
'server.now': 'now',
'server.noData': 'no reading',
diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx
index 0e65e4a..23494a3 100644
--- a/frontend/src/pages/Settings.jsx
+++ b/frontend/src/pages/Settings.jsx
@@ -1189,7 +1189,13 @@ export default function Settings({ me }) {
if (error) return <>{header}Fehler beim Laden ({error}).
>;
if (!data || !form) return <>{header}>;
- const channelItems = data.channels.map((c) => ({ id: c.id, label: `#${c.name}`, hint: c.guild }));
+ // Foren stehen mit dabei — sichtbar gekennzeichnet, weil dort nur der
+ // Voting-Kanal sinnvoll ist: posten kann der Bot in ein Forum nicht
+ const channelItems = data.channels.map((c) => ({
+ id: c.id,
+ label: `${c.forum ? '📋' : '#'}${c.name}`,
+ hint: c.forum ? 'Forum' : c.guild,
+ }));
const roleItems = (data.roles ?? []).map((r) => ({ id: r.id, label: `@${r.name}`, hint: r.guild }));
// Der Composer bringt seine eigene Auswahl mit und erwartet -Elemente
const channelOptions = channelItems.map((c) => (
diff --git a/frontend/src/style.css b/frontend/src/style.css
index bccd794..4f334d8 100644
--- a/frontend/src/style.css
+++ b/frontend/src/style.css
@@ -1981,9 +1981,11 @@ label.toggle {
}
.ep-weg:hover { color: #f23f43; }
+/* Hängt an und wird per JS positioniert — im zweispaltigen Teil der
+ Config (CSS columns) zerreißt es absolut positionierte Kästen sonst, und die
+ Tafel landet in der Nachbarspalte. */
.ep-tafel {
- position: absolute; z-index: 60; top: calc(100% + .35rem); left: 0;
- width: min(21rem, 78vw);
+ position: fixed; z-index: 1100;
background: var(--bg2); border: 1px solid var(--border); border-radius: 6px;
box-shadow: 0 12px 30px rgba(0, 0, 0, .55);
animation: riseIn .15s ease both;
diff --git a/src/web/api.js b/src/web/api.js
index 37a9684..4a0dc54 100644
--- a/src/web/api.js
+++ b/src/web/api.js
@@ -1,5 +1,5 @@
// REST-API fürs Webinterface: Devlogs öffentlich, Commits + Settings nur für den Admin
-import { EmbedBuilder } from 'discord.js';
+import { ChannelType, EmbedBuilder } from 'discord.js';
import {
listDevlogs, listDevlogsByProject, devlogHeads,
searchDevlogs, getDevlog, listCommits, listReleases, archiveStats,
@@ -833,12 +833,23 @@ ${rssItems}
// --- Settings (Admin) ---
/** Alle Textkanäle, die der Bot sehen kann (für die Dropdowns) */
+ // Ein Forum ist nicht „textBased" — es hat keine Nachrichten, nur Beitraege.
+ // Fuers Voting ist es trotzdem ein gueltiges Ziel, also muss es hier
+ // auftauchen; sonst steht es schlicht nicht zur Auswahl.
+ const FORUM_TYPEN = new Set([ChannelType.GuildForum, ChannelType.GuildMedia]);
+ const istWaehlbar = (ch) => ch.isTextBased?.() || FORUM_TYPEN.has(ch.type);
+
function listChannels() {
const channels = [];
for (const guild of client.guilds.cache.values()) {
for (const ch of guild.channels.cache.values()) {
- if (ch.isTextBased?.() && ch.viewable !== false) {
- channels.push({ id: ch.id, name: ch.name, guild: guild.name });
+ if (istWaehlbar(ch) && ch.viewable !== false) {
+ channels.push({
+ id: ch.id,
+ name: ch.name,
+ guild: guild.name,
+ forum: FORUM_TYPEN.has(ch.type),
+ });
}
}
}
@@ -1010,7 +1021,7 @@ ${rssItems}
continue;
}
const ch = client.channels.cache.get(value);
- if (!ch?.isTextBased?.()) {
+ if (!ch || !istWaehlbar(ch)) {
return reply.code(400).send({ error: `${key}: Kanal nicht gefunden` });
}
setSetting(key, value);