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}

Lade …

; - 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