Update mit Presence-Einstellungen
All checks were successful
release-tag / release-image (push) Successful in 2m8s
All checks were successful
release-tag / release-image (push) Successful in 2m8s
This commit is contained in:
29
README.md
29
README.md
@@ -341,3 +341,32 @@ http://127.0.0.1:8080/?token=ChangeMePlease
|
||||
```
|
||||
|
||||
Für Docker sollte `DASHBOARD_ADDR=0.0.0.0:8080` gesetzt werden. Das Dashboard ist als internes Admin-/Team-Werkzeug gedacht. Stelle es nicht ungeschützt öffentlich ins Internet. Nutze mindestens `DASHBOARD_TOKEN`; für produktiven Betrieb idealerweise zusätzlich Reverse Proxy mit HTTPS und Zugriffsbeschränkung.
|
||||
|
||||
## Webinterface: Rich Presence & Session-Einladung
|
||||
|
||||
Im Dashboard gibt es jetzt den Bereich **Rich Presence & Session-Einladung**. Dort können Admins über das Webinterface konfigurieren:
|
||||
|
||||
- Bot-Status: online, idle, dnd, invisible
|
||||
- Activity-Typ: spielt, schaut, hört, tritt an, custom
|
||||
- Name, State, Details
|
||||
- Start- und Endzeit
|
||||
- Large-/Small-Image-Asset-Keys und Texte
|
||||
- Discord-Zielchannel-ID
|
||||
- Post-Channel-ID für einen Session-Invite
|
||||
- Button-Label, Post-Titel und Post-Text
|
||||
|
||||
Wichtig: Discord erlaubt Bot-Activities offiziell nur eingeschränkt. Die Discord-Dokumentation sagt, dass Bot-User nur `name`, `state`, `type` und `url` setzen können. Deshalb speichert das Dashboard zwar Details, Assets und Timestamps und sendet sie mit, aber Discord-Clients können diese Felder ignorieren. Für den eigentlichen Einladungszweck erzeugt der Button **Session-Invite posten** zuverlässig einen Discord-Post mit Link-Button zum konfigurierten Channel.
|
||||
|
||||
Der Channel-Link wird aus `DISCORD_GUILD_ID` und der im Webinterface eingetragenen Zielchannel-ID erzeugt:
|
||||
|
||||
```text
|
||||
https://discord.com/channels/<GuildID>/<ChannelID>
|
||||
```
|
||||
|
||||
Der Bot benötigt im Invite-Post-Channel:
|
||||
|
||||
```text
|
||||
View Channel
|
||||
Send Messages
|
||||
Embed Links
|
||||
```
|
||||
|
||||
25
db.go
25
db.go
@@ -106,6 +106,9 @@ CREATE INDEX IF NOT EXISTS idx_order_events_order ON order_events(order_id);
|
||||
if err := s.createTradingSchema(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.createAppSettingsSchema(); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := s.db.Exec(`INSERT OR IGNORE INTO storage_locations(name, normalized_name, created_by_id, created_by_name, created_at, updated_at)
|
||||
SELECT DISTINCT TRIM(location), lower(TRIM(location)), 'migration', 'migration', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
|
||||
FROM inventory
|
||||
@@ -847,3 +850,25 @@ func (s *Store) SummarizeTradingByUser(month, year int) ([]TradingTraderSummary,
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Store) createAppSettingsSchema() error {
|
||||
_, err := s.db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS app_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL DEFAULT '',
|
||||
updated_at DATETIME NOT NULL
|
||||
);`)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) SetSetting(key, value string) error {
|
||||
_, err := s.db.Exec(`INSERT INTO app_settings(key, value, updated_at) VALUES(?, ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at`, key, value, time.Now().UTC())
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) GetSetting(key string) (string, error) {
|
||||
var value string
|
||||
err := s.db.QueryRow(`SELECT value FROM app_settings WHERE key=?`, key).Scan(&value)
|
||||
return value, err
|
||||
}
|
||||
|
||||
3
main.go
3
main.go
@@ -23,7 +23,8 @@ func main() {
|
||||
if err := bot.Start(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
dashboard := StartDashboard(cfg, store)
|
||||
bot.ApplySavedRichPresence()
|
||||
dashboard := StartDashboard(cfg, store, bot)
|
||||
log.Println("Bot läuft. Beenden mit Ctrl+C.")
|
||||
stop := make(chan os.Signal, 1)
|
||||
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
22
models.go
22
models.go
@@ -148,3 +148,25 @@ type TradingPurposeSummary struct {
|
||||
SCU float64
|
||||
Runs int
|
||||
}
|
||||
|
||||
type RichPresenceConfig struct {
|
||||
Enabled bool
|
||||
Status string
|
||||
ActivityType string
|
||||
Name string
|
||||
State string
|
||||
Details string
|
||||
StartTimestamp int64
|
||||
EndTimestamp int64
|
||||
LargeImage string
|
||||
LargeText string
|
||||
SmallImage string
|
||||
SmallText string
|
||||
InviteChannelID string
|
||||
InvitePostChannelID string
|
||||
InviteButtonLabel string
|
||||
InvitePostTitle string
|
||||
InvitePostText string
|
||||
LastInviteMessageID string
|
||||
LastInviteChannelID string
|
||||
}
|
||||
|
||||
192
rich_presence.go
Normal file
192
rich_presence.go
Normal file
@@ -0,0 +1,192 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
const richPresenceSettingKey = "rich_presence_config"
|
||||
|
||||
func defaultRichPresenceConfig() RichPresenceConfig {
|
||||
return RichPresenceConfig{
|
||||
Enabled: false,
|
||||
Status: "online",
|
||||
ActivityType: "watching",
|
||||
Name: "SF Trading",
|
||||
State: "Gemeinsame Session",
|
||||
Details: "Bereit für Orga-Aktivitäten",
|
||||
InviteButtonLabel: "Channel beitreten",
|
||||
InvitePostTitle: "Gemeinsame Session",
|
||||
InvitePostText: "Tritt dem Discord-Channel bei und schließe dich der Session an.",
|
||||
InviteChannelID: "",
|
||||
InvitePostChannelID: "",
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Store) GetRichPresenceConfig() (RichPresenceConfig, error) {
|
||||
cfg := defaultRichPresenceConfig()
|
||||
raw, err := s.GetSetting(richPresenceSettingKey)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return cfg, nil
|
||||
}
|
||||
return cfg, err
|
||||
}
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return cfg, nil
|
||||
}
|
||||
if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
|
||||
return cfg, err
|
||||
}
|
||||
if cfg.Status == "" {
|
||||
cfg.Status = "online"
|
||||
}
|
||||
if cfg.ActivityType == "" {
|
||||
cfg.ActivityType = "watching"
|
||||
}
|
||||
if cfg.InviteButtonLabel == "" {
|
||||
cfg.InviteButtonLabel = "Channel beitreten"
|
||||
}
|
||||
if cfg.InvitePostTitle == "" {
|
||||
cfg.InvitePostTitle = "Gemeinsame Session"
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func (s *Store) SaveRichPresenceConfig(cfg RichPresenceConfig) error {
|
||||
b, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.SetSetting(richPresenceSettingKey, string(b))
|
||||
}
|
||||
|
||||
func (b *Bot) ApplySavedRichPresence() {
|
||||
cfg, err := b.store.GetRichPresenceConfig()
|
||||
if err != nil {
|
||||
log.Println("rich presence config load failed:", err)
|
||||
return
|
||||
}
|
||||
if err := b.SetRichPresence(cfg); err != nil {
|
||||
log.Println("rich presence apply failed:", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bot) SetRichPresence(cfg RichPresenceConfig) error {
|
||||
if b == nil || b.dg == nil {
|
||||
return fmt.Errorf("bot session nicht verfügbar")
|
||||
}
|
||||
if !cfg.Enabled {
|
||||
return b.dg.UpdateStatusComplex(discordgo.UpdateStatusData{Status: cfg.Status, Activities: []*discordgo.Activity{}})
|
||||
}
|
||||
activity := &discordgo.Activity{
|
||||
Name: firstNonEmpty(cfg.Name, "SF Trading"),
|
||||
Type: activityTypeFromString(cfg.ActivityType),
|
||||
State: strings.TrimSpace(cfg.State),
|
||||
Details: strings.TrimSpace(cfg.Details),
|
||||
Assets: discordgo.Assets{
|
||||
LargeImageID: strings.TrimSpace(cfg.LargeImage),
|
||||
LargeText: strings.TrimSpace(cfg.LargeText),
|
||||
SmallImageID: strings.TrimSpace(cfg.SmallImage),
|
||||
SmallText: strings.TrimSpace(cfg.SmallText),
|
||||
},
|
||||
Timestamps: discordgo.TimeStamps{StartTimestamp: cfg.StartTimestamp, EndTimestamp: cfg.EndTimestamp},
|
||||
}
|
||||
// Discord beschränkt Bot-Presences offiziell auf name/state/type/url. Details, Assets und Timestamps
|
||||
// werden mitgesendet, können clientseitig aber ignoriert werden. Der Session-Invite-Post enthält
|
||||
// deshalb den zuverlässigen Button-Link.
|
||||
return b.dg.UpdateStatusComplex(discordgo.UpdateStatusData{Status: firstNonEmpty(cfg.Status, "online"), Activities: []*discordgo.Activity{activity}})
|
||||
}
|
||||
|
||||
func (b *Bot) SendRichPresenceInvite(cfg RichPresenceConfig) (*discordgo.Message, error) {
|
||||
if strings.TrimSpace(cfg.InvitePostChannelID) == "" {
|
||||
return nil, fmt.Errorf("Invite-Post-Channel fehlt")
|
||||
}
|
||||
link := channelLink(b.cfg.GuildID, cfg.InviteChannelID)
|
||||
if link == "" {
|
||||
return nil, fmt.Errorf("Invite-Channel fehlt")
|
||||
}
|
||||
buttonLabel := firstNonEmpty(cfg.InviteButtonLabel, "Channel beitreten")
|
||||
emb := &discordgo.MessageEmbed{
|
||||
Title: firstNonEmpty(cfg.InvitePostTitle, "Gemeinsame Session"),
|
||||
Description: firstNonEmpty(cfg.InvitePostText, "Tritt dem Discord-Channel bei und schließe dich der Session an."),
|
||||
Color: 0x38bdf8,
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
Fields: []*discordgo.MessageEmbedField{
|
||||
{Name: "Channel", Value: fmt.Sprintf("<#%s>", cfg.InviteChannelID), Inline: true},
|
||||
{Name: "Status", Value: firstNonEmpty(cfg.State, cfg.Details, cfg.Name, "Session aktiv"), Inline: true},
|
||||
},
|
||||
}
|
||||
msg, err := b.dg.ChannelMessageSendComplex(cfg.InvitePostChannelID, &discordgo.MessageSend{
|
||||
Embeds: []*discordgo.MessageEmbed{emb},
|
||||
Components: []discordgo.MessageComponent{discordgo.ActionsRow{Components: []discordgo.MessageComponent{discordgo.Button{Label: buttonLabel, Style: discordgo.LinkButton, URL: link}}}},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.LastInviteChannelID = msg.ChannelID
|
||||
cfg.LastInviteMessageID = msg.ID
|
||||
_ = b.store.SaveRichPresenceConfig(cfg)
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
func activityTypeFromString(v string) discordgo.ActivityType {
|
||||
switch strings.ToLower(strings.TrimSpace(v)) {
|
||||
case "playing", "game", "spielt":
|
||||
return discordgo.ActivityTypeGame
|
||||
case "streaming":
|
||||
return discordgo.ActivityTypeStreaming
|
||||
case "listening", "hört":
|
||||
return discordgo.ActivityTypeListening
|
||||
case "competing":
|
||||
return discordgo.ActivityTypeCompeting
|
||||
case "custom":
|
||||
return discordgo.ActivityTypeCustom
|
||||
default:
|
||||
return discordgo.ActivityTypeWatching
|
||||
}
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, v := range values {
|
||||
if strings.TrimSpace(v) != "" {
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func channelLink(guildID, channelID string) string {
|
||||
guildID = strings.TrimSpace(guildID)
|
||||
channelID = strings.TrimSpace(channelID)
|
||||
if guildID == "" || channelID == "" {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("https://discord.com/channels/%s/%s", guildID, channelID)
|
||||
}
|
||||
|
||||
func unixMillisFromDatetimeLocal(v string) int64 {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
return 0
|
||||
}
|
||||
for _, layout := range []string{"2006-01-02T15:04", "2006-01-02T15:04:05", time.RFC3339} {
|
||||
if t, err := time.ParseInLocation(layout, v, time.Local); err == nil {
|
||||
return t.UnixMilli()
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func datetimeLocalFromUnixMillis(ms int64) string {
|
||||
if ms <= 0 {
|
||||
return ""
|
||||
}
|
||||
return time.UnixMilli(ms).Local().Format("2006-01-02T15:04")
|
||||
}
|
||||
144
web_dashboard.go
144
web_dashboard.go
@@ -11,6 +11,10 @@ import (
|
||||
)
|
||||
|
||||
type dashboardData struct {
|
||||
Presence RichPresenceConfig
|
||||
PresenceStart string
|
||||
PresenceEnd string
|
||||
PresenceNotice string
|
||||
Now time.Time
|
||||
Month int
|
||||
Year int
|
||||
@@ -24,13 +28,15 @@ type dashboardData struct {
|
||||
Error string
|
||||
}
|
||||
|
||||
func StartDashboard(cfg Config, store *Store) *http.Server {
|
||||
func StartDashboard(cfg Config, store *Store, bot *Bot) *http.Server {
|
||||
if strings.TrimSpace(cfg.DashboardAddr) == "" {
|
||||
return nil
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
h := &dashboardHandler{cfg: cfg, store: store}
|
||||
h := &dashboardHandler{cfg: cfg, store: store, bot: bot}
|
||||
mux.HandleFunc("/", h.index)
|
||||
mux.HandleFunc("/presence", h.presence)
|
||||
mux.HandleFunc("/presence/invite", h.presenceInvite)
|
||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("ok")) })
|
||||
srv := &http.Server{Addr: cfg.DashboardAddr, Handler: mux}
|
||||
go func() {
|
||||
@@ -45,6 +51,7 @@ func StartDashboard(cfg Config, store *Store) *http.Server {
|
||||
type dashboardHandler struct {
|
||||
cfg Config
|
||||
store *Store
|
||||
bot *Bot
|
||||
}
|
||||
|
||||
func (h *dashboardHandler) authorized(r *http.Request) bool {
|
||||
@@ -69,6 +76,11 @@ func (h *dashboardHandler) index(w http.ResponseWriter, r *http.Request) {
|
||||
month := intFromQuery(r, "monat", int(now.Month()))
|
||||
year := intFromQuery(r, "jahr", now.Year())
|
||||
data := dashboardData{Now: now, Month: month, Year: year, Token: r.URL.Query().Get("token"), TradingPayee: h.cfg.TradingPayoutRecipient}
|
||||
if rp, err := h.store.GetRichPresenceConfig(); err == nil {
|
||||
data.Presence = rp
|
||||
data.PresenceStart = datetimeLocalFromUnixMillis(rp.StartTimestamp)
|
||||
data.PresenceEnd = datetimeLocalFromUnixMillis(rp.EndTimestamp)
|
||||
}
|
||||
var errs []string
|
||||
if orders, err := h.store.ListOrders("", 25); err == nil {
|
||||
data.Orders = orders
|
||||
@@ -102,6 +114,105 @@ func (h *dashboardHandler) index(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
func (h *dashboardHandler) presence(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.authorized(r) {
|
||||
w.Header().Set("WWW-Authenticate", "Bearer")
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodPost {
|
||||
http.Redirect(w, r, dashboardURL("/", r), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
cfg := RichPresenceConfig{
|
||||
Enabled: r.FormValue("enabled") == "on",
|
||||
Status: strings.TrimSpace(r.FormValue("status")),
|
||||
ActivityType: strings.TrimSpace(r.FormValue("activity_type")),
|
||||
Name: strings.TrimSpace(r.FormValue("name")),
|
||||
State: strings.TrimSpace(r.FormValue("state")),
|
||||
Details: strings.TrimSpace(r.FormValue("details")),
|
||||
StartTimestamp: unixMillisFromDatetimeLocal(r.FormValue("start")),
|
||||
EndTimestamp: unixMillisFromDatetimeLocal(r.FormValue("end")),
|
||||
LargeImage: strings.TrimSpace(r.FormValue("large_image")),
|
||||
LargeText: strings.TrimSpace(r.FormValue("large_text")),
|
||||
SmallImage: strings.TrimSpace(r.FormValue("small_image")),
|
||||
SmallText: strings.TrimSpace(r.FormValue("small_text")),
|
||||
InviteChannelID: strings.TrimSpace(r.FormValue("invite_channel_id")),
|
||||
InvitePostChannelID: strings.TrimSpace(r.FormValue("invite_post_channel_id")),
|
||||
InviteButtonLabel: strings.TrimSpace(r.FormValue("invite_button_label")),
|
||||
InvitePostTitle: strings.TrimSpace(r.FormValue("invite_post_title")),
|
||||
InvitePostText: strings.TrimSpace(r.FormValue("invite_post_text")),
|
||||
}
|
||||
old, _ := h.store.GetRichPresenceConfig()
|
||||
cfg.LastInviteChannelID = old.LastInviteChannelID
|
||||
cfg.LastInviteMessageID = old.LastInviteMessageID
|
||||
if cfg.Status == "" {
|
||||
cfg.Status = "online"
|
||||
}
|
||||
if cfg.ActivityType == "" {
|
||||
cfg.ActivityType = "watching"
|
||||
}
|
||||
if cfg.InviteButtonLabel == "" {
|
||||
cfg.InviteButtonLabel = "Channel beitreten"
|
||||
}
|
||||
if cfg.InvitePostTitle == "" {
|
||||
cfg.InvitePostTitle = "Gemeinsame Session"
|
||||
}
|
||||
if err := h.store.SaveRichPresenceConfig(cfg); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if h.bot != nil {
|
||||
if err := h.bot.SetRichPresence(cfg); err != nil {
|
||||
http.Error(w, "Gespeichert, aber Presence konnte nicht gesetzt werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
http.Redirect(w, r, dashboardURL("/?presence=saved", r), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (h *dashboardHandler) presenceInvite(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.authorized(r) {
|
||||
w.Header().Set("WWW-Authenticate", "Bearer")
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodPost {
|
||||
http.Redirect(w, r, dashboardURL("/", r), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
cfg, err := h.store.GetRichPresenceConfig()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if h.bot == nil {
|
||||
http.Error(w, "Bot-Session nicht verfügbar", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if _, err := h.bot.SendRichPresenceInvite(cfg); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, dashboardURL("/?presence=invite", r), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func dashboardURL(path string, r *http.Request) string {
|
||||
token := r.URL.Query().Get("token")
|
||||
if token == "" {
|
||||
return path
|
||||
}
|
||||
sep := "?"
|
||||
if strings.Contains(path, "?") {
|
||||
sep = "&"
|
||||
}
|
||||
return path + sep + "token=" + token
|
||||
}
|
||||
|
||||
func intFromQuery(r *http.Request, key string, fallback int) int {
|
||||
v := strings.TrimSpace(r.URL.Query().Get(key))
|
||||
if v == "" {
|
||||
@@ -137,7 +248,7 @@ var dashboardTemplate = template.Must(template.New("dashboard").Funcs(template.F
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>SF Trading Dashboard</title>
|
||||
<style>
|
||||
:root{--bg:#0f172a;--panel:#111827;--card:#1f2937;--text:#e5e7eb;--muted:#94a3b8;--accent:#38bdf8;--green:#22c55e;--red:#ef4444;--yellow:#f59e0b}*{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,Segoe UI,sans-serif;background:var(--bg);color:var(--text)}header{padding:24px 28px;border-bottom:1px solid #334155;background:#020617;position:sticky;top:0;z-index:1}h1{margin:0;font-size:24px}main{padding:24px;display:grid;gap:20px}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:16px}.card{background:var(--card);border:1px solid #334155;border-radius:16px;padding:16px;box-shadow:0 10px 24px rgba(0,0,0,.22)}.metric{font-size:28px;font-weight:700}.muted{color:var(--muted)}table{width:100%;border-collapse:collapse;font-size:14px}th,td{padding:9px 8px;border-bottom:1px solid #334155;text-align:left;vertical-align:top}th{color:#cbd5e1;background:#111827}.scroll{overflow:auto}.badge{display:inline-block;border-radius:999px;padding:2px 8px;background:#334155;color:#dbeafe;font-size:12px}.err{background:#7f1d1d;border-color:#fecaca}.filters{display:flex;gap:8px;align-items:end;flex-wrap:wrap}.filters input{background:#020617;color:var(--text);border:1px solid #475569;border-radius:10px;padding:8px}.filters button{background:var(--accent);border:0;border-radius:10px;padding:9px 14px;font-weight:700;color:#00111c}a{color:#7dd3fc}</style>
|
||||
:root{--bg:#0f172a;--panel:#111827;--card:#1f2937;--text:#e5e7eb;--muted:#94a3b8;--accent:#38bdf8;--green:#22c55e;--red:#ef4444;--yellow:#f59e0b}*{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,Segoe UI,sans-serif;background:var(--bg);color:var(--text)}header{padding:24px 28px;border-bottom:1px solid #334155;background:#020617;position:sticky;top:0;z-index:1}h1{margin:0;font-size:24px}main{padding:24px;display:grid;gap:20px}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:16px}.card{background:var(--card);border:1px solid #334155;border-radius:16px;padding:16px;box-shadow:0 10px 24px rgba(0,0,0,.22)}.metric{font-size:28px;font-weight:700}.muted{color:var(--muted)}table{width:100%;border-collapse:collapse;font-size:14px}th,td{padding:9px 8px;border-bottom:1px solid #334155;text-align:left;vertical-align:top}th{color:#cbd5e1;background:#111827}.scroll{overflow:auto}.badge{display:inline-block;border-radius:999px;padding:2px 8px;background:#334155;color:#dbeafe;font-size:12px}.err{background:#7f1d1d;border-color:#fecaca}.filters{display:flex;gap:8px;align-items:end;flex-wrap:wrap}.filters input{background:#020617;color:var(--text);border:1px solid #475569;border-radius:10px;padding:8px}.filters button,.btn{background:var(--accent);border:0;border-radius:10px;padding:9px 14px;font-weight:700;color:#00111c;text-decoration:none;cursor:pointer}.formgrid{display:grid;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));gap:12px}.formgrid input,.formgrid select,.formgrid textarea{width:100%;background:#020617;color:var(--text);border:1px solid #475569;border-radius:10px;padding:8px}.formgrid textarea{min-height:76px}.actions{display:flex;gap:10px;flex-wrap:wrap;margin-top:12px}.hint{font-size:13px;color:var(--muted);line-height:1.4}a{color:#7dd3fc}</style>
|
||||
</head>
|
||||
<body>
|
||||
<header><h1>SF Trading Dashboard</h1><div class="muted">Stand: {{.Now.Format "02.01.2006 15:04"}}</div></header>
|
||||
@@ -152,6 +263,33 @@ var dashboardTemplate = template.Must(template.New("dashboard").Funcs(template.F
|
||||
<span class="muted">Filter betrifft Trading-Auswertung und Abrechnung.</span>
|
||||
</form>
|
||||
</section>
|
||||
<section class="card"><h2>Rich Presence & Session-Einladung</h2>
|
||||
<p class="hint">Admin-Steuerung über das Webinterface. Hinweis: Discord erlaubt Bot-Activities offiziell nur eingeschränkt; der zuverlässige Einladungsbutton wird deshalb zusätzlich als Discord-Post mit Link-Button erzeugt.</p>
|
||||
<form method="post" action="/presence{{if .Token}}?token={{.Token}}{{end}}">
|
||||
<div class="formgrid">
|
||||
<label><input type="checkbox" name="enabled" {{if .Presence.Enabled}}checked{{end}}> Presence aktiv</label>
|
||||
<label>Status<br><select name="status"><option value="online" {{if eq .Presence.Status "online"}}selected{{end}}>online</option><option value="idle" {{if eq .Presence.Status "idle"}}selected{{end}}>idle</option><option value="dnd" {{if eq .Presence.Status "dnd"}}selected{{end}}>nicht stören</option><option value="invisible" {{if eq .Presence.Status "invisible"}}selected{{end}}>unsichtbar</option></select></label>
|
||||
<label>Activity-Typ<br><select name="activity_type"><option value="playing" {{if eq .Presence.ActivityType "playing"}}selected{{end}}>Spielt</option><option value="watching" {{if eq .Presence.ActivityType "watching"}}selected{{end}}>Schaut</option><option value="listening" {{if eq .Presence.ActivityType "listening"}}selected{{end}}>Hört</option><option value="competing" {{if eq .Presence.ActivityType "competing"}}selected{{end}}>Tritt an</option><option value="custom" {{if eq .Presence.ActivityType "custom"}}selected{{end}}>Custom</option></select></label>
|
||||
<label>Name<br><input name="name" value="{{.Presence.Name}}" placeholder="Star Citizen"></label>
|
||||
<label>State<br><input name="state" value="{{.Presence.State}}" placeholder="Mining Session"></label>
|
||||
<label>Details<br><input name="details" value="{{.Presence.Details}}" placeholder="Join im Voice"></label>
|
||||
<label>Start<br><input type="datetime-local" name="start" value="{{.PresenceStart}}"></label>
|
||||
<label>Ende<br><input type="datetime-local" name="end" value="{{.PresenceEnd}}"></label>
|
||||
<label>Large Image Asset-Key<br><input name="large_image" value="{{.Presence.LargeImage}}" placeholder="sc_logo"></label>
|
||||
<label>Large Image Text<br><input name="large_text" value="{{.Presence.LargeText}}"></label>
|
||||
<label>Small Image Asset-Key<br><input name="small_image" value="{{.Presence.SmallImage}}"></label>
|
||||
<label>Small Image Text<br><input name="small_text" value="{{.Presence.SmallText}}"></label>
|
||||
<label>Discord-Zielchannel-ID<br><input name="invite_channel_id" value="{{.Presence.InviteChannelID}}" placeholder="Voice/Text Channel ID"></label>
|
||||
<label>Post-Channel-ID<br><input name="invite_post_channel_id" value="{{.Presence.InvitePostChannelID}}" placeholder="Channel für Einladungs-Post"></label>
|
||||
<label>Button-Label<br><input name="invite_button_label" value="{{.Presence.InviteButtonLabel}}" placeholder="Channel beitreten"></label>
|
||||
<label>Post-Titel<br><input name="invite_post_title" value="{{.Presence.InvitePostTitle}}"></label>
|
||||
<label style="grid-column:1/-1">Post-Text<br><textarea name="invite_post_text">{{.Presence.InvitePostText}}</textarea></label>
|
||||
</div>
|
||||
<div class="actions"><button class="btn" type="submit">Speichern & Presence setzen</button></div>
|
||||
</form>
|
||||
<form method="post" action="/presence/invite{{if .Token}}?token={{.Token}}{{end}}" class="actions"><button class="btn" type="submit">Session-Invite posten</button>{{if .Presence.LastInviteMessageID}}<span class="muted">Letzter Post: #{{.Presence.LastInviteMessageID}}</span>{{end}}</form>
|
||||
</section>
|
||||
|
||||
{{with .TradingSummary}}
|
||||
<section class="grid">
|
||||
<div class="card"><div class="muted">Trading-Einträge</div><div class="metric">{{.Count}}</div></div>
|
||||
|
||||
Reference in New Issue
Block a user