Round-6 follow-up: UX polish + client-IP fix #26
@@ -74,6 +74,7 @@ h1 { font-size: 1.4rem; margin: 0 0 0.4rem; }
|
|||||||
.subnav a.active { color: var(--ink); }
|
.subnav a.active { color: var(--ink); }
|
||||||
|
|
||||||
.form { display: flex; flex-wrap: wrap; gap: 0.6rem; align-items: end; margin-top: 0.4rem; }
|
.form { display: flex; flex-wrap: wrap; gap: 0.6rem; align-items: end; margin-top: 0.4rem; }
|
||||||
|
.form .export { margin-left: auto; align-self: center; color: var(--accent); white-space: nowrap; }
|
||||||
.form.col { flex-direction: column; align-items: stretch; max-width: 540px; }
|
.form.col { flex-direction: column; align-items: stretch; max-width: 540px; }
|
||||||
.form label { display: flex; flex-direction: column; gap: 0.2rem; font-size: 0.85rem; color: var(--ink-dim); }
|
.form label { display: flex; flex-direction: column; gap: 0.2rem; font-size: 0.85rem; color: var(--ink-dim); }
|
||||||
.form input, .form select, .form textarea {
|
.form input, .form select, .form textarea {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
<input name="name" value="{{.NameMask}}" placeholder="sender name mask (* ?)">
|
<input name="name" value="{{.NameMask}}" placeholder="sender name mask (* ?)">
|
||||||
<input name="ext" value="{{.ExtMask}}" placeholder="sender external id mask (* ?)">
|
<input name="ext" value="{{.ExtMask}}" placeholder="sender external id mask (* ?)">
|
||||||
<button type="submit">Filter</button>
|
<button type="submit">Filter</button>
|
||||||
|
<a class="export" href="/_gm/messages.csv?{{.FilterQuery}}">Export CSV ↓</a>
|
||||||
</form>
|
</form>
|
||||||
{{if or .GameID .UserID}}
|
{{if or .GameID .UserID}}
|
||||||
<p class="note">Filtered{{if .GameID}} to game <a href="/_gm/games/{{.GameID}}">{{.GameID}}</a>{{end}}{{if .UserID}} from <a href="/_gm/users/{{.UserID}}">sender</a>{{end}} · <a href="/_gm/messages">clear</a></p>
|
<p class="note">Filtered{{if .GameID}} to game <a href="/_gm/games/{{.GameID}}">{{.GameID}}</a>{{end}}{{if .UserID}} from <a href="/_gm/users/{{.UserID}}">sender</a>{{end}} · <a href="/_gm/messages">clear</a></p>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package server
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"encoding/csv"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
@@ -53,6 +54,7 @@ func (s *Server) registerConsole(router *gin.Engine) {
|
|||||||
gm.GET("/complaints/:id", s.consoleComplaintDetail)
|
gm.GET("/complaints/:id", s.consoleComplaintDetail)
|
||||||
gm.POST("/complaints/:id/resolve", s.consoleResolveComplaint)
|
gm.POST("/complaints/:id/resolve", s.consoleResolveComplaint)
|
||||||
gm.GET("/messages", s.consoleMessages)
|
gm.GET("/messages", s.consoleMessages)
|
||||||
|
gm.GET("/messages.csv", s.consoleMessagesCSV)
|
||||||
gm.GET("/dictionary", s.consoleDictionary)
|
gm.GET("/dictionary", s.consoleDictionary)
|
||||||
gm.POST("/dictionary/reload", s.consoleReloadDictionary)
|
gm.POST("/dictionary/reload", s.consoleReloadDictionary)
|
||||||
gm.POST("/dictionary/changes/apply", s.consoleApplyChanges)
|
gm.POST("/dictionary/changes/apply", s.consoleApplyChanges)
|
||||||
@@ -186,6 +188,38 @@ func (s *Server) consoleMessages(c *gin.Context) {
|
|||||||
s.renderConsole(c, "messages", "messages", "Messages", view)
|
s.renderConsole(c, "messages", "messages", "Messages", view)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// adminMessagesExportCap bounds the CSV export row count (the moderated chat volume is small).
|
||||||
|
const adminMessagesExportCap = 100000
|
||||||
|
|
||||||
|
// consoleMessagesCSV exports the whole filtered chat-message list (ignoring pagination) as a
|
||||||
|
// CSV download, for offline moderation review.
|
||||||
|
func (s *Server) consoleMessagesCSV(c *gin.Context) {
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
gameID, _ := uuid.Parse(strings.TrimSpace(c.Query("game")))
|
||||||
|
userID, _ := uuid.Parse(strings.TrimSpace(c.Query("user")))
|
||||||
|
filter := social.AdminMessageFilter{
|
||||||
|
GameID: gameID,
|
||||||
|
SenderID: userID,
|
||||||
|
NameMask: c.Query("name"),
|
||||||
|
ExtMask: c.Query("ext"),
|
||||||
|
}
|
||||||
|
items, err := s.social.AdminListMessages(ctx, filter, adminMessagesExportCap, 0)
|
||||||
|
if err != nil {
|
||||||
|
s.consoleError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Header("Content-Type", "text/csv; charset=utf-8")
|
||||||
|
c.Header("Content-Disposition", `attachment; filename="messages.csv"`)
|
||||||
|
w := csv.NewWriter(c.Writer)
|
||||||
|
_ = w.Write([]string{"time", "source", "sender_id", "sender", "ip", "message", "game_id"})
|
||||||
|
for _, m := range items {
|
||||||
|
_ = w.Write([]string{
|
||||||
|
fmtTime(m.CreatedAt), m.Source, m.SenderID.String(), m.SenderName, m.SenderIP, m.Body, m.GameID.String(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
w.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
// consoleUserDetail renders one account with its stats, identities and games.
|
// consoleUserDetail renders one account with its stats, identities and games.
|
||||||
func (s *Server) consoleUserDetail(c *gin.Context) {
|
func (s *Server) consoleUserDetail(c *gin.Context) {
|
||||||
ctx := c.Request.Context()
|
ctx := c.Request.Context()
|
||||||
|
|||||||
Reference in New Issue
Block a user