Foren waehlbar, Emoji-Tafel repariert, Balken sagt seit wann er misst
Drei Dinge, die beim Ausprobieren aufgefallen sind. ## Foren standen nicht zur Auswahl Ein Forum-Kanal ist fuer discord.js nicht "textBased" — er hat keine Nachrichten, nur Beitraege. Die Kanal-Liste im Panel hat genau danach gefiltert, also tauchte das Forum nirgends auf, und beim Speichern haette dieselbe Pruefung es abgelehnt. Beides kennt jetzt Foren und Medien-Kanaele; in der Auswahl stehen sie mit 📋 statt # und dem Hinweis "Forum". Das erklaert auch das "Unknown Channel" beim Testen: eingestellt war noch der alte Kanal, und der neue liess sich gar nicht erst waehlen. ## Emoji-Tafel landete in der Nachbarspalte Die Config laeuft ab genug Breite zweispaltig — CSS `columns: 2`. In einem Mehrspalten-Layout zerreisst es absolut positionierte Kaesten: die Tafel wurde fragmentiert und ihr Inhalt in der anderen Spalte gezeichnet, waehrend beim Feld nur der leere Rahmen stehen blieb. Genau so sah es auch aus. Die Tafel haengt jetzt an <body> und wird per JS an den Knopf gerechnet, wie der Dialog auch. Sie klappt nach oben, wenn unten kein Platz ist, wandert beim Scrollen mit und bleibt beim Fenstergroesse-Aendern an ihrem Feld. ## Balken sagt jetzt, seit wann er misst Die Waechter-Balken sahen leer aus. Sind sie nicht: 545 Messpunkte, alle gruen — aber eben nur von heute, weil die Dienste gestern angelegt wurden. Sechs von sieben Tagen sind zu Recht grau, das sieht nur aus wie kaputt. Steht links jetzt "misst seit 01.08." statt stur "vor 7 Tagen", sobald die erste Messung deutlich spaeter liegt als der Anfang des Fensters. Bei vollem Verlauf bleibt es bei "vor 7 Tagen". Geprueft im Browser: Tafel an <body> und `position: fixed`, richtig positioniert und nach oben geklappt, alle sieben Gruppen und 127 Symbole in einem Stueck; Forum in der Auswahlliste mit Kennzeichnung; beide Beschriftungen des Balkens (angebrochener und voller Verlauf). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 <body>, 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 = '🙂' }) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{offen && (
|
||||
<div className="ep-tafel">
|
||||
{offen && platz && createPortal(
|
||||
<div
|
||||
className="ep-tafel"
|
||||
ref={tafel}
|
||||
style={{ left: platz.left, top: platz.top, width: platz.breite }}
|
||||
>
|
||||
<div className="ep-suche">
|
||||
<IconSearch size={13} />
|
||||
<input
|
||||
@@ -160,7 +194,8 @@ export default function EmojiPick({ value, onChange, placeholder = '🙂' }) {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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' }) {
|
||||
))}
|
||||
</div>
|
||||
<div className="up-track-foot">
|
||||
<span>{modus === 'tage' ? t('status.days90') : t('server.days7')}</span>
|
||||
<span>{linkeBeschriftung}</span>
|
||||
{quote != null && (
|
||||
<span className={quote >= 99 ? 'up-good' : quote >= 90 ? '' : 'up-bad'}>
|
||||
{quote.toLocaleString(tag, { minimumFractionDigits: 1, maximumFractionDigits: 1 })} %
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -1189,7 +1189,13 @@ export default function Settings({ me }) {
|
||||
if (error) return <>{header}<section className="content"><p className="notice">Fehler beim Laden ({error}).</p></section></>;
|
||||
if (!data || !form) return <>{header}<section className="content"><p className="notice">Lade …</p></section></>;
|
||||
|
||||
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 <option>-Elemente
|
||||
const channelOptions = channelItems.map((c) => (
|
||||
|
||||
@@ -1981,9 +1981,11 @@ label.toggle {
|
||||
}
|
||||
.ep-weg:hover { color: #f23f43; }
|
||||
|
||||
/* Hängt an <body> 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;
|
||||
|
||||
+15
-4
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user