1662 lines
59 KiB
GDScript
1662 lines
59 KiB
GDScript
extends Panel
|
|
## Server Admin Panel — Management for Users and Leaderboards.
|
|
## Features: User BAN/DELETE/ROLE management, Leaderboard score modification.
|
|
|
|
signal closed
|
|
|
|
# -- UI refs --
|
|
@onready var tabs := %Tabs as TabContainer
|
|
@onready var title_label := %Title as Label
|
|
@onready var count_label := %CountLabel as Label
|
|
@onready var refresh_btn := %RefreshBtn as Button
|
|
@onready var close_btn := %CloseBtn as Button
|
|
@onready var status_label := %StatusLabel as Label
|
|
|
|
# Tab: Users
|
|
@onready var user_tree := %UserTree as Tree
|
|
@onready var select_all_btn := %SelectAllBtn as Button
|
|
@onready var deselect_btn := %DeselectBtn as Button
|
|
@onready var selected_label := %SelectedLabel as Label
|
|
@onready var ban_btn := %BanBtn as Button
|
|
@onready var unban_btn := %UnbanBtn as Button
|
|
@onready var delete_btn := %DeleteBtn as Button
|
|
@onready var history_btn := %HistoryBtn as Button
|
|
@onready var history_dialog := %HistoryDialog as AcceptDialog
|
|
@onready var history_text := %HistoryText as RichTextLabel
|
|
|
|
# Tab: Leaderboards
|
|
@onready var lb_tree := %LeaderboardTree as Tree
|
|
@onready var sync_lb_btn := %SyncLeaderboardBtn as Button
|
|
@onready var reset_lb_btn := %ResetLBBtn as Button
|
|
|
|
# Tab: Daily Rewards
|
|
@onready var month_option_btn := %MonthOptionBtn as OptionButton
|
|
@onready var days_grid := %DaysGrid as GridContainer
|
|
@onready var day_config_template := %DayConfigTemplate as PanelContainer
|
|
@onready var load_dr_btn := %LoadDRConfigBtn as Button
|
|
@onready var save_dr_btn := %SaveDRConfigBtn as Button
|
|
|
|
var _daily_reward_config_data: Dictionary = {}
|
|
var _current_dr_month: String = ""
|
|
|
|
# Tab: Announcements
|
|
@onready var target_user_edit := %TargetUserEdit as LineEdit
|
|
@onready var find_user_btn := %FindUserBtn as Button
|
|
@onready var resolved_id_label := %ResolvedIdLabel as Label
|
|
@onready var title_edit := %TitleEdit as LineEdit
|
|
@onready var content_edit := %ContentEdit as TextEdit
|
|
@onready var start_date_edit := %StartDatePicker as Button
|
|
@onready var end_date_edit := %EndDatePicker as Button
|
|
@onready var add_reward_btn := %AddRewardBtn as Button
|
|
@onready var rewards_list := %RewardsList as VBoxContainer
|
|
@onready var reward_row_template := %RewardRowTemplate as HBoxContainer
|
|
@onready var send_mail_btn := %SendMailBtn as Button
|
|
|
|
var _resolved_user_id: String = ""
|
|
|
|
# Tab: Mail Manager
|
|
@onready var mail_tree := %MailTree as Tree
|
|
@onready var refresh_mail_btn := %RefreshMailBtn as Button
|
|
@onready var edit_mail_btn := %EditMailBtn as Button
|
|
@onready var end_mail_btn := %EndMailBtn as Button
|
|
@onready var delete_mail_server_btn := %DeleteMailServerBtn as Button
|
|
|
|
var _mail_root: TreeItem
|
|
var _all_server_mails: Array = []
|
|
|
|
# Tab: Shop (Featured Banners)
|
|
@onready var slots_vbox := %SlotsVBox as VBoxContainer
|
|
@onready var load_banners_btn := %LoadBannersBtn as Button
|
|
@onready var save_banners_btn := %SaveBannersBtn as Button
|
|
|
|
# Tab: Lobby Chat
|
|
@onready var chat_prefix_edit := %PrefixEdit as LineEdit
|
|
@onready var chat_max_msg_spin := %MaxMsgSpin as SpinBox
|
|
@onready var chat_max_age_spin := %MaxAgeSpin as SpinBox
|
|
@onready var chat_wipe_btn := %WipeChatBtn as Button
|
|
@onready var chat_purge_btn := %PurgeOldBtn as Button
|
|
@onready var chat_save_btn := %SaveConfigBtn as Button
|
|
@onready var chat_status_label := %ChatStatusLabel as Label
|
|
|
|
# Tab: Chat Storage
|
|
@onready var chat_channel_id_edit := %ChannelIdEdit as LineEdit
|
|
@onready var load_messages_btn := %LoadMessagesBtn as Button
|
|
@onready var chat_tree := %ChatTree as Tree
|
|
@onready var refresh_chat_btn := %RefreshChatBtn as Button
|
|
@onready var delete_selected_btn := %DeleteSelectedBtn as Button
|
|
|
|
var _chat_tree_root: TreeItem
|
|
var _chat_channel_id: String = ""
|
|
var _chat_cursor: String = ""
|
|
var _chat_messages_data: Array = []
|
|
|
|
const MONTH_NAMES = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]
|
|
|
|
# -- Data --
|
|
var all_users: Array = []
|
|
var lb_data: Array = []
|
|
var _user_root: TreeItem
|
|
var _lb_root: TreeItem
|
|
|
|
# -- Colors --
|
|
const CLR_BG := Color(0.1, 0.1, 0.12)
|
|
const CLR_HEADER := Color(0.85, 0.55, 0.2)
|
|
const CLR_TITLE := Color(0.95, 0.35, 0.35)
|
|
const CLR_TEXT := Color(0.82, 0.82, 0.82)
|
|
const CLR_DIM := Color(0.5, 0.5, 0.5)
|
|
const CLR_BANNED := Color(1.0, 0.3, 0.3)
|
|
const CLR_ADMIN := Color(0.4, 0.75, 1.0)
|
|
const CLR_MOD := Color(0.5, 0.9, 0.4)
|
|
const CLR_BTN_BAN := Color(1.0, 0.6, 0.2)
|
|
const CLR_BTN_UNBAN := Color(0.4, 0.8, 0.4)
|
|
const CLR_BTN_DEL := Color(1.0, 0.3, 0.3)
|
|
const CLR_STATUS_OK := Color(0.4, 0.9, 0.4)
|
|
const CLR_STATUS_ERR := Color(1.0, 0.3, 0.3)
|
|
|
|
func _ready() -> void:
|
|
visible = false
|
|
_apply_plain_style()
|
|
_setup_columns()
|
|
_connect_signals()
|
|
|
|
# =============================================================================
|
|
# Plain style (overrides any inherited theme)
|
|
# =============================================================================
|
|
func _apply_plain_style() -> void:
|
|
title_label.add_theme_color_override("font_color", CLR_TITLE)
|
|
title_label.add_theme_font_size_override("font_size", 22)
|
|
count_label.add_theme_color_override("font_color", CLR_HEADER)
|
|
status_label.add_theme_color_override("font_color", CLR_DIM)
|
|
|
|
for btn: Button in [refresh_btn, close_btn, select_all_btn, deselect_btn, load_dr_btn, save_dr_btn]:
|
|
_style_button(btn, Color(0.2, 0.2, 0.24), CLR_TEXT)
|
|
_style_button(ban_btn, Color(0.3, 0.2, 0.1), CLR_BTN_BAN)
|
|
_style_button(unban_btn, Color(0.1, 0.22, 0.1), CLR_BTN_UNBAN)
|
|
_style_button(delete_btn, Color(0.28, 0.1, 0.1), CLR_BTN_DEL)
|
|
_style_button(sync_lb_btn, Color(0.2, 0.2, 0.3), CLR_ADMIN)
|
|
|
|
var tree_bg := StyleBoxFlat.new()
|
|
tree_bg.bg_color = Color(0.12, 0.12, 0.14)
|
|
tree_bg.set_corner_radius_all(4)
|
|
|
|
for t: Tree in [user_tree, lb_tree]:
|
|
t.add_theme_stylebox_override("panel", tree_bg)
|
|
t.add_theme_color_override("font_color", CLR_TEXT)
|
|
t.add_theme_color_override("title_button_color", CLR_HEADER)
|
|
|
|
func _style_button(btn: Button, bg_color: Color, text_color: Color) -> void:
|
|
var sb := StyleBoxFlat.new()
|
|
sb.bg_color = bg_color
|
|
sb.set_corner_radius_all(4)
|
|
sb.set_content_margin_all(6)
|
|
btn.add_theme_stylebox_override("normal", sb)
|
|
var sb_hover := sb.duplicate()
|
|
sb_hover.bg_color = bg_color.lightened(0.15)
|
|
btn.add_theme_stylebox_override("hover", sb_hover)
|
|
btn.add_theme_color_override("font_color", text_color)
|
|
|
|
# =============================================================================
|
|
# Columns Setup
|
|
# =============================================================================
|
|
func _setup_columns() -> void:
|
|
# Users
|
|
user_tree.set_column_title(0, "☑")
|
|
user_tree.set_column_title(1, "User ID")
|
|
user_tree.set_column_title(2, "Username")
|
|
user_tree.set_column_title(3, "Display Name")
|
|
user_tree.set_column_title(4, "Created")
|
|
user_tree.set_column_title(5, "Edit")
|
|
user_tree.set_column_custom_minimum_width(0, 40)
|
|
user_tree.set_column_expand(0, false)
|
|
_user_root = user_tree.create_item()
|
|
|
|
# Leaderboards
|
|
lb_tree.set_column_title(0, "Rank")
|
|
lb_tree.set_column_title(1, "Player")
|
|
lb_tree.set_column_title(2, "High Score")
|
|
lb_tree.set_column_title(3, "Wins")
|
|
lb_tree.set_column_title(4, "Games")
|
|
lb_tree.set_column_title(5, "Edit")
|
|
lb_tree.set_column_expand(0, false)
|
|
lb_tree.set_column_custom_minimum_width(0, 60)
|
|
lb_tree.set_column_expand(5, false)
|
|
lb_tree.set_column_custom_minimum_width(5, 60)
|
|
_lb_root = lb_tree.create_item()
|
|
|
|
# Mail Manager
|
|
mail_tree.set_column_title(0, "Type")
|
|
mail_tree.set_column_title(1, "Title")
|
|
mail_tree.set_column_title(2, "Sender")
|
|
mail_tree.set_column_title(3, "Start")
|
|
mail_tree.set_column_title(4, "Expires")
|
|
mail_tree.set_column_title(5, "Status")
|
|
mail_tree.set_column_custom_minimum_width(0, 100)
|
|
mail_tree.set_column_expand(0, false)
|
|
mail_tree.set_column_custom_minimum_width(5, 80)
|
|
mail_tree.set_column_expand(5, false)
|
|
_mail_root = mail_tree.create_item()
|
|
|
|
# Chat Storage
|
|
chat_tree.set_column_title(0, "Select")
|
|
chat_tree.set_column_title(1, "Sender")
|
|
chat_tree.set_column_title(2, "Content")
|
|
chat_tree.set_column_title(3, "Date / ID")
|
|
chat_tree.set_column_custom_minimum_width(0, 70)
|
|
chat_tree.set_column_expand(0, false)
|
|
chat_tree.set_column_custom_minimum_width(1, 100)
|
|
chat_tree.set_column_expand(2, true)
|
|
chat_tree.set_column_custom_minimum_width(3, 180)
|
|
_chat_tree_root = chat_tree.create_item()
|
|
|
|
func _connect_signals() -> void:
|
|
close_btn.pressed.connect(_on_close)
|
|
refresh_btn.pressed.connect(_on_refresh)
|
|
tabs.tab_changed.connect(_on_tab_changed)
|
|
|
|
# User actions
|
|
select_all_btn.pressed.connect(_select_all)
|
|
deselect_btn.pressed.connect(_deselect_all)
|
|
ban_btn.pressed.connect(_on_ban)
|
|
unban_btn.pressed.connect(_on_unban)
|
|
delete_btn.pressed.connect(_on_delete)
|
|
history_btn.pressed.connect(_on_history_pressed)
|
|
user_tree.item_edited.connect(_on_user_tree_item_edited)
|
|
user_tree.button_clicked.connect(_on_user_tree_button_clicked)
|
|
|
|
# LB actions
|
|
lb_tree.button_clicked.connect(_on_lb_tree_button_clicked)
|
|
sync_lb_btn.pressed.connect(_on_sync_leaderboard)
|
|
|
|
# DR actions
|
|
load_dr_btn.pressed.connect(_load_daily_rewards_config)
|
|
save_dr_btn.pressed.connect(_save_daily_rewards_config)
|
|
month_option_btn.item_selected.connect(_on_dr_month_selected)
|
|
|
|
# Announcement actions
|
|
send_mail_btn.pressed.connect(_on_send_mail)
|
|
add_reward_btn.pressed.connect(_on_add_reward_pressed)
|
|
find_user_btn.pressed.connect(_on_find_user)
|
|
target_user_edit.text_changed.connect(func(_t): _resolved_user_id = ""; resolved_id_label.text = "")
|
|
|
|
# Mail Manager actions
|
|
refresh_mail_btn.pressed.connect(func(): await _load_mail())
|
|
mail_tree.item_selected.connect(_on_mail_item_selected)
|
|
edit_mail_btn.pressed.connect(_on_edit_mail_pressed)
|
|
end_mail_btn.pressed.connect(_on_end_mail_pressed)
|
|
delete_mail_server_btn.pressed.connect(_on_delete_mail_server_pressed)
|
|
_update_mail_action_btns(null)
|
|
|
|
# Shop actions
|
|
load_banners_btn.pressed.connect(func(): await _load_featured_banners())
|
|
save_banners_btn.pressed.connect(func(): await _save_featured_banners())
|
|
|
|
# Chat actions
|
|
chat_wipe_btn.pressed.connect(_on_wipe_chat)
|
|
chat_purge_btn.pressed.connect(_on_purge_old_chat)
|
|
chat_save_btn.pressed.connect(_on_save_chat_config)
|
|
|
|
# Chat Storage actions
|
|
load_messages_btn.pressed.connect(_on_load_chat_messages)
|
|
refresh_chat_btn.pressed.connect(_on_load_more_chat_messages)
|
|
delete_selected_btn.pressed.connect(_on_delete_selected_chat_messages)
|
|
|
|
# =============================================================================
|
|
# Core Panel Logic
|
|
# =============================================================================
|
|
func show_panel() -> void:
|
|
visible = true
|
|
_on_tab_changed(tabs.current_tab)
|
|
|
|
func _on_close() -> void:
|
|
visible = false
|
|
emit_signal("closed")
|
|
|
|
func _on_refresh() -> void:
|
|
_on_tab_changed(tabs.current_tab)
|
|
|
|
func _on_tab_changed(tab_index: int) -> void:
|
|
_set_status("")
|
|
if tab_index == 0:
|
|
await _load_users()
|
|
elif tab_index == 1:
|
|
await _load_leaderboard()
|
|
elif tab_index == 2:
|
|
await _load_daily_rewards_config()
|
|
elif tab_index == 3:
|
|
_update_announcement_count()
|
|
elif tab_index == 4:
|
|
await _load_mail()
|
|
elif tab_index == 5:
|
|
await _load_featured_banners()
|
|
elif tab_index == 6:
|
|
await _load_chat_config()
|
|
elif tab_index == 7:
|
|
await _on_load_chat_messages()
|
|
|
|
# =============================================================================
|
|
# RPC Helper
|
|
# =============================================================================
|
|
func _rpc(rpc_name: String, payload: Dictionary) -> Dictionary:
|
|
if not NakamaManager.client or not NakamaManager.session:
|
|
return {"error": "Not connected"}
|
|
var result = await BackendService.api_rpc_async(rpc_name, JSON.stringify(payload))
|
|
if result.get("success", false) == false:
|
|
var err: String = str(result.get("message", "Unknown error"))
|
|
_set_status(err, CLR_STATUS_ERR)
|
|
return {"error": err}
|
|
return result.get("data", {})
|
|
|
|
func _set_status(msg: String, color: Color = CLR_DIM) -> void:
|
|
status_label.text = msg
|
|
status_label.add_theme_color_override("font_color", color)
|
|
|
|
# =============================================================================
|
|
# TAB 1: USER MANAGEMENT
|
|
# =============================================================================
|
|
func _load_users() -> void:
|
|
_clear_tree(user_tree, _user_root)
|
|
_set_status("Loading users...")
|
|
|
|
var res := await _rpc("admin_list_users", {})
|
|
if res.has("error"):
|
|
_set_status("Failed: " + str(res.error), CLR_STATUS_ERR)
|
|
return
|
|
|
|
var raw_users = res.get("users", [])
|
|
all_users = raw_users if typeof(raw_users) == TYPE_ARRAY else []
|
|
count_label.text = "%d users" % all_users.size()
|
|
|
|
for user in all_users:
|
|
var uid: String = user.get("user_id", "")
|
|
var uname: String = user.get("username", "")
|
|
var dname: String = user.get("display_name", uname)
|
|
var role: String = user.get("role", "player")
|
|
var banned: bool = user.get("banned", false)
|
|
var created: String = str(user.get("create_time", ""))
|
|
|
|
var item := _user_root.create_child()
|
|
item.set_cell_mode(0, TreeItem.CELL_MODE_CHECK)
|
|
item.set_editable(0, true)
|
|
|
|
var short_id := uid.substr(0, 8) + "..."
|
|
item.set_text(1, short_id)
|
|
item.set_tooltip_text(1, uid)
|
|
|
|
var uname_display := uname
|
|
if role != "player": uname_display += " [%s]" % role.to_upper()
|
|
if banned: uname_display += " (BANNED)"
|
|
item.set_text(2, uname_display)
|
|
item.set_text(3, dname)
|
|
item.set_text(4, created.substr(0, 10) if created.length() > 10 else created)
|
|
item.add_button(5, _get_edit_icon(), 0, false, "Edit User")
|
|
|
|
if banned: item.set_custom_color(2, CLR_BANNED)
|
|
elif role in ["admin", "owner"]: item.set_custom_color(2, CLR_ADMIN)
|
|
|
|
item.set_metadata(0, user)
|
|
|
|
_update_selection_count()
|
|
_set_status("")
|
|
|
|
func _on_user_tree_item_edited() -> void:
|
|
_update_selection_count()
|
|
|
|
func _update_selection_count() -> void:
|
|
var count := _get_checked_items().size()
|
|
selected_label.text = "%d selected" % count
|
|
ban_btn.disabled = count == 0
|
|
unban_btn.disabled = count == 0
|
|
delete_btn.disabled = count == 0
|
|
|
|
func _on_user_tree_button_clicked(item: TreeItem, _col: int, _id: int, _mouse: int) -> void:
|
|
_show_edit_user_dialog(item.get_metadata(0))
|
|
|
|
func _show_edit_user_dialog(user: Dictionary) -> void:
|
|
var uid: String = user.get("user_id", "")
|
|
var uname: String = user.get("username", "")
|
|
var display_name: String = user.get("display_name", uname)
|
|
var role: String = user.get("role", "player")
|
|
var banned: bool = user.get("banned", false)
|
|
var detail := await _rpc("admin_get_user_detail", {"user_id": uid})
|
|
var detail_user: Dictionary = detail.get("user", {}) if not detail.has("error") else {}
|
|
|
|
var dialog := AcceptDialog.new()
|
|
dialog.title = "Edit User: " + uname
|
|
dialog.min_size = Vector2i(460, 420)
|
|
var vbox := VBoxContainer.new()
|
|
vbox.add_theme_constant_override("separation", 8)
|
|
|
|
var id_lbl := Label.new()
|
|
id_lbl.text = "User ID: " + uid
|
|
id_lbl.add_theme_color_override("font_color", CLR_DIM)
|
|
vbox.add_child(id_lbl)
|
|
|
|
var email_lbl := Label.new()
|
|
var email = detail_user.get("email", "")
|
|
var verified = detail_user.get("email_verified", false)
|
|
email_lbl.text = "Email: %s (%s)" % [email if not str(email).is_empty() else "none", "verified" if verified else "unverified"]
|
|
email_lbl.add_theme_color_override("font_color", CLR_DIM)
|
|
vbox.add_child(email_lbl)
|
|
|
|
var name_grid := GridContainer.new()
|
|
name_grid.columns = 2
|
|
name_grid.add_theme_constant_override("h_separation", 8)
|
|
name_grid.add_theme_constant_override("v_separation", 8)
|
|
var username_lbl := Label.new(); username_lbl.text = "Username:"; name_grid.add_child(username_lbl)
|
|
var username_input := LineEdit.new(); username_input.text = detail_user.get("username", uname); name_grid.add_child(username_input)
|
|
var display_lbl := Label.new(); display_lbl.text = "Display Name:"; name_grid.add_child(display_lbl)
|
|
var display_input := LineEdit.new(); display_input.text = detail_user.get("display_name", display_name); name_grid.add_child(display_input)
|
|
var password_lbl := Label.new(); password_lbl.text = "New Password:"; name_grid.add_child(password_lbl)
|
|
var password_input := LineEdit.new(); password_input.placeholder_text = "Leave empty to keep"; password_input.secret = true; name_grid.add_child(password_input)
|
|
vbox.add_child(name_grid)
|
|
|
|
var role_hbox := HBoxContainer.new()
|
|
var role_lbl := Label.new()
|
|
role_lbl.text = "Role: "
|
|
role_hbox.add_child(role_lbl)
|
|
var role_option := OptionButton.new()
|
|
var roles := ["player", "moderator", "admin"]
|
|
for r in roles: role_option.add_item(r)
|
|
role_option.selected = roles.find(role)
|
|
role_hbox.add_child(role_option)
|
|
vbox.add_child(role_hbox)
|
|
|
|
var ban_check := CheckButton.new()
|
|
ban_check.text = "Banned"
|
|
ban_check.button_pressed = banned
|
|
vbox.add_child(ban_check)
|
|
|
|
var reason_input := LineEdit.new()
|
|
reason_input.placeholder_text = "Ban reason..."
|
|
reason_input.text = user.get("ban_reason", "")
|
|
reason_input.visible = banned
|
|
vbox.add_child(reason_input)
|
|
ban_check.toggled.connect(func(on): reason_input.visible = on)
|
|
|
|
var save_btn := Button.new()
|
|
save_btn.text = "Save Changes"
|
|
save_btn.custom_minimum_size.y = 36
|
|
vbox.add_child(save_btn)
|
|
|
|
dialog.add_child(vbox)
|
|
add_child(dialog)
|
|
dialog.popup_centered()
|
|
|
|
save_btn.pressed.connect(func():
|
|
var new_role: String = roles[role_option.selected]
|
|
await _save_user_edit(uid, username_input.text.strip_edges(), display_input.text.strip_edges(), password_input.text, new_role, ban_check.button_pressed, reason_input.text)
|
|
dialog.queue_free()
|
|
)
|
|
|
|
func _save_user_edit(uid: String, uname: String, display_name: String, new_password: String, new_role: String, new_banned: bool, reason: String) -> void:
|
|
_set_status("Saving...")
|
|
var identity_res := await _rpc("admin_update_user_identity", {
|
|
"user_id": uid,
|
|
"username": uname,
|
|
"display_name": display_name
|
|
})
|
|
if identity_res.has("error"):
|
|
_set_status("Identity save failed: " + str(identity_res.error), CLR_STATUS_ERR)
|
|
return
|
|
if not new_password.strip_edges().is_empty():
|
|
var password_res := await _rpc("admin_set_user_password", {"user_id": uid, "password": new_password})
|
|
if password_res.has("error"):
|
|
_set_status("Password save failed: " + str(password_res.error), CLR_STATUS_ERR)
|
|
return
|
|
await _rpc("admin_set_user_role", {"user_id": uid, "role": new_role})
|
|
if new_banned:
|
|
await _rpc("admin_ban_player", {"user_id": uid, "reason": reason, "duration_hours": 0})
|
|
else:
|
|
await _rpc("admin_unban_player", {"user_id": uid})
|
|
_set_status("Saved: " + display_name, CLR_STATUS_OK)
|
|
await _load_users()
|
|
|
|
# =============================================================================
|
|
# TAB 1: USER SELECTION BATCH ACTIONS
|
|
# =============================================================================
|
|
func _select_all() -> void:
|
|
if not _user_root: return
|
|
var item := _user_root.get_first_child()
|
|
while item:
|
|
item.set_checked(0, true)
|
|
item = item.get_next()
|
|
_update_selection_count()
|
|
|
|
func _deselect_all() -> void:
|
|
if not _user_root: return
|
|
var item := _user_root.get_first_child()
|
|
while item:
|
|
item.set_checked(0, false)
|
|
item = item.get_next()
|
|
_update_selection_count()
|
|
|
|
func _get_checked_items() -> Array[TreeItem]:
|
|
var items: Array[TreeItem] = []
|
|
if not _user_root: return items
|
|
var item := _user_root.get_first_child()
|
|
while item:
|
|
if item.is_checked(0): items.append(item)
|
|
item = item.get_next()
|
|
return items
|
|
|
|
func _get_checked_user_data() -> Array:
|
|
var data: Array = []
|
|
for item in _get_checked_items():
|
|
data.append(item.get_metadata(0))
|
|
return data
|
|
|
|
func _on_ban() -> void:
|
|
var users := _get_checked_user_data()
|
|
if users.is_empty(): return
|
|
var to_ban: Array = []
|
|
for u in users:
|
|
if u.get("banned", false): continue
|
|
if u.get("role", "") in ["admin", "owner"]: continue
|
|
to_ban.append(u)
|
|
if to_ban.is_empty():
|
|
_set_status("No eligible users to ban", CLR_STATUS_ERR)
|
|
return
|
|
|
|
var dialog := ConfirmationDialog.new()
|
|
dialog.title = "Ban %d user(s)" % to_ban.size()
|
|
var vbox := VBoxContainer.new()
|
|
vbox.add_theme_constant_override("separation", 8)
|
|
var list_names: Array = []
|
|
for i in range(min(to_ban.size(), 10)): list_names.append(to_ban[i].get("username", ""))
|
|
var name_str := ", ".join(list_names)
|
|
if to_ban.size() > 10: name_str += "... and %d others" % (to_ban.size() - 10)
|
|
var lbl := Label.new()
|
|
lbl.text = "Ban following users:\n" + name_str
|
|
lbl.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
|
lbl.custom_minimum_size = Vector2(350, 0)
|
|
vbox.add_child(lbl)
|
|
var reason_input := LineEdit.new()
|
|
reason_input.placeholder_text = "Ban reason"
|
|
vbox.add_child(reason_input)
|
|
var dur := SpinBox.new()
|
|
dur.min_value = 0; dur.max_value = 8760; dur.value = 24; dur.prefix = "Hours: "
|
|
vbox.add_child(dur)
|
|
dialog.add_child(vbox)
|
|
add_child(dialog)
|
|
dialog.popup_centered()
|
|
dialog.confirmed.connect(func():
|
|
var ok := 0
|
|
for u in to_ban:
|
|
var r := await _rpc("admin_ban_player", {"user_id": u.user_id, "reason": reason_input.text, "duration_hours": int(dur.value)})
|
|
if r.get("success", false): ok += 1
|
|
_set_status("Banned %d/%d" % [ok, to_ban.size()], CLR_STATUS_OK)
|
|
await _load_users()
|
|
dialog.queue_free()
|
|
)
|
|
|
|
func _on_unban() -> void:
|
|
var users := _get_checked_user_data()
|
|
var to_unban := []; for u in users: if u.get("banned", false): to_unban.append(u)
|
|
if to_unban.is_empty(): return
|
|
var ok := 0
|
|
for u in to_unban:
|
|
var r := await _rpc("admin_unban_player", {"user_id": u.user_id})
|
|
if r.get("success", false): ok += 1
|
|
_set_status("Unbanned %d/%d" % [ok, to_unban.size()], CLR_STATUS_OK)
|
|
await _load_users()
|
|
|
|
func _on_history_pressed() -> void:
|
|
var selected_data = _get_checked_user_data()
|
|
if selected_data.size() != 1:
|
|
_set_status("Please select exactly ONE user to view history.", CLR_STATUS_ERR)
|
|
return
|
|
|
|
var uid = selected_data[0].get("user_id", "")
|
|
_set_status("Fetching user details...", CLR_STATUS_OK)
|
|
var detail_res = await _rpc("admin_get_user_detail", {"user_id": uid})
|
|
var res = await _rpc("admin_get_user_history", {"user_id": uid})
|
|
|
|
if detail_res.has("error") and res.has("error"):
|
|
_set_status("Failed to get user details: " + str(detail_res.error), CLR_STATUS_ERR)
|
|
return
|
|
|
|
_set_status("User details loaded.", CLR_STATUS_OK)
|
|
|
|
var h = res.get("history", {}) if not res.has("error") else {}
|
|
var details = detail_res if not detail_res.has("error") else {}
|
|
var detail_user: Dictionary = details.get("user", {})
|
|
var text = "[b]=== USER DETAIL ===[/b]\n"
|
|
text += "User ID: " + uid + "\n\n"
|
|
text += "[b]-- Account --[/b]\n"
|
|
text += "Username: %s\n" % detail_user.get("username", "")
|
|
text += "Display Name: %s\n" % detail_user.get("display_name", "")
|
|
text += "Email: %s (%s)\n" % [detail_user.get("email", "none"), "verified" if detail_user.get("email_verified", false) else "unverified"]
|
|
text += "Created: %s\n" % str(detail_user.get("create_time", ""))
|
|
text += "Wallet: %s\n" % str(detail_user.get("wallet", {}))
|
|
text += "Subscription: %s\n\n" % str(details.get("subscription", {}))
|
|
|
|
text += "[b]-- Friends --[/b]\n"
|
|
var friends = details.get("friends", [])
|
|
if friends.is_empty():
|
|
text += "No friends found.\n"
|
|
else:
|
|
for f in friends:
|
|
text += "- %s (%s) state=%s\n" % [f.get("username", ""), f.get("user_id", ""), str(f.get("state", ""))]
|
|
text += "\n"
|
|
|
|
text += "[b]-- Purchase History / Receipts --[/b]\n"
|
|
var purchases = details.get("purchases", [])
|
|
if purchases.is_empty():
|
|
text += "No purchases found.\n"
|
|
else:
|
|
for p in purchases:
|
|
text += "- %s: %s\n" % [p.get("key", ""), str(p.get("value", {}))]
|
|
text += "\n"
|
|
|
|
# Logins
|
|
text += "[b]-- Recent Logins --[/b]\n"
|
|
var logins = h.get("logins", [])
|
|
if logins.is_empty():
|
|
text += "No recent logins found.\n"
|
|
else:
|
|
for l in logins:
|
|
var time_str = Time.get_datetime_string_from_unix_time(int(l.get("time", 0)))
|
|
text += "- %s (IP: %s)\n" % [time_str, l.get("ip", "unknown")]
|
|
|
|
text += "\n"
|
|
|
|
# Wallet Ledger
|
|
text += "[b]-- Economy / Wallet Ledger --[/b]\n"
|
|
var ledger = h.get("wallet_ledger", [])
|
|
if ledger.is_empty():
|
|
text += "No transactions found.\n"
|
|
else:
|
|
for item in ledger:
|
|
var changeset = str(item.get("changeset", {}))
|
|
var c_time = item.get("create_time", "")
|
|
text += "- [%s] %s\n" % [c_time.left(19).replace("T", " "), changeset]
|
|
|
|
text += "\n"
|
|
|
|
# Matches
|
|
text += "[b]-- Matches --[/b]\n"
|
|
var matches = h.get("matches", [])
|
|
if matches.is_empty():
|
|
text += "No match history found.\n"
|
|
else:
|
|
for m in matches:
|
|
text += "- " + str(m) + "\n"
|
|
|
|
text += "\n[b]-- Storage Objects --[/b]\n"
|
|
var storage = details.get("storage", {})
|
|
if storage.is_empty():
|
|
text += "No storage objects found.\n"
|
|
else:
|
|
for collection in storage.keys():
|
|
var objects = storage[collection]
|
|
text += "\n[b]%s[/b] (%d)\n" % [collection, objects.size()]
|
|
for obj in objects:
|
|
text += "- %s: %s\n" % [obj.get("key", ""), str(obj.get("value", {}))]
|
|
|
|
history_text.text = text
|
|
history_dialog.popup_centered()
|
|
|
|
func _on_delete() -> void:
|
|
var users := _get_checked_user_data()
|
|
if users.is_empty(): return
|
|
var ids := []; var names := []
|
|
for u in users:
|
|
if u.get("role", "") in ["admin", "owner"]: continue
|
|
ids.append(u.user_id); names.append(u.username)
|
|
if ids.is_empty(): return
|
|
var dialog := ConfirmationDialog.new()
|
|
dialog.title = "DELETE %d account(s)" % ids.size()
|
|
var vbox := VBoxContainer.new(); vbox.add_theme_constant_override("separation", 12)
|
|
var list_names = []; for i in range(min(names.size(), 10)): list_names.append(names[i])
|
|
var name_str = ", ".join(list_names); if names.size() > 10: name_str += "... and %d others" % (names.size() - 10)
|
|
var text_lbl = Label.new(); text_lbl.text = "PERMANENTLY DELETE:\n" + name_str + "\n\nThis CANNOT be undone!"; text_lbl.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART; text_lbl.custom_minimum_size = Vector2(400, 0)
|
|
vbox.add_child(text_lbl); dialog.add_child(vbox); add_child(dialog); dialog.popup_centered()
|
|
dialog.confirmed.connect(func():
|
|
var r := await _rpc("admin_delete_users", {"user_ids": ids})
|
|
_set_status("Deleted %d accounts" % r.get("deleted", []).size(), CLR_STATUS_OK)
|
|
await _load_users()
|
|
dialog.queue_free()
|
|
)
|
|
|
|
# =============================================================================
|
|
# TAB 2: LEADERBOARD MANAGEMENT
|
|
# =============================================================================
|
|
func _load_leaderboard() -> void:
|
|
_clear_tree(lb_tree, _lb_root)
|
|
_set_status("Loading leaderboards...")
|
|
|
|
var res := await _rpc("get_leaderboard_stats", {})
|
|
if res.has("error"):
|
|
_set_status("Failed to load scores", CLR_STATUS_ERR)
|
|
return
|
|
|
|
var raw_lb = res.get("leaderboard", [])
|
|
if typeof(raw_lb) == TYPE_ARRAY:
|
|
lb_data = raw_lb
|
|
elif typeof(raw_lb) == TYPE_DICTIONARY:
|
|
lb_data = raw_lb.values()
|
|
else:
|
|
lb_data = []
|
|
|
|
if lb_data.is_empty():
|
|
lb_data = await _fetch_native_leaderboard_for_admin()
|
|
count_label.text = "%d records" % lb_data.size()
|
|
lb_data.sort_custom(func(a, b): return a.get("high_score", 0) > b.get("high_score", 0))
|
|
|
|
var rank = 1
|
|
for entry in lb_data:
|
|
var item := _lb_root.create_child()
|
|
item.set_text(0, "#%d" % rank)
|
|
item.set_text(1, entry.get("display_name", "Unknown"))
|
|
item.set_text(2, str(entry.get("high_score", 0)))
|
|
item.set_text(3, str(entry.get("games_won", 0)))
|
|
item.set_text(4, str(entry.get("games_played", 0)))
|
|
item.add_button(5, _get_edit_icon(), 0, false, "Edit Score")
|
|
item.set_metadata(0, entry)
|
|
rank += 1
|
|
_set_status("")
|
|
|
|
func _fetch_native_leaderboard_for_admin() -> Array:
|
|
var result = await NakamaManager.client.list_leaderboard_records_async(
|
|
NakamaManager.session,
|
|
"global_high_score",
|
|
[],
|
|
null,
|
|
100
|
|
)
|
|
if result.is_exception():
|
|
push_warning("[AdminPanel] Native leaderboard load failed: " + result.get_exception().message)
|
|
return []
|
|
|
|
var data: Array = []
|
|
for record in result.records:
|
|
var meta: Dictionary = {}
|
|
if record.metadata and not record.metadata.is_empty():
|
|
var parsed = JSON.parse_string(record.metadata)
|
|
if parsed is Dictionary:
|
|
meta = parsed
|
|
data.append({
|
|
"user_id": record.owner_id,
|
|
"username": record.username,
|
|
"display_name": record.username if (record.username and not record.username.is_empty()) else "Unknown",
|
|
"avatar_url": meta.get("avatar_url", ""),
|
|
"loadout_character": meta.get("loadout_character", "Copper"),
|
|
"high_score": int(record.score),
|
|
"games_played": int(meta.get("games_played", 0)),
|
|
"games_won": int(meta.get("games_won", 0))
|
|
})
|
|
return data
|
|
|
|
func _on_lb_tree_button_clicked(item: TreeItem, _col: int, _id: int, _mouse: int) -> void:
|
|
_show_edit_score_dialog(item.get_metadata(0))
|
|
|
|
func _show_edit_score_dialog(entry: Dictionary) -> void:
|
|
var uid: String = entry.get("user_id", "")
|
|
var name: String = entry.get("display_name", "Unknown")
|
|
var dialog := AcceptDialog.new(); dialog.title = "Edit Score: " + name; dialog.min_size = Vector2i(350, 280)
|
|
var vbox := VBoxContainer.new(); vbox.add_theme_constant_override("separation", 10)
|
|
var info_lbl := Label.new(); info_lbl.text = "Manage player stats for: " + name; info_lbl.add_theme_color_override("font_color", CLR_HEADER); vbox.add_child(info_lbl)
|
|
var grid := GridContainer.new(); grid.columns = 2
|
|
var fields = {"high_score": SpinBox.new(), "games_won": SpinBox.new(), "games_played": SpinBox.new()}
|
|
for key in fields:
|
|
var lbl = Label.new()
|
|
lbl.text = key.capitalize() + ":"
|
|
grid.add_child(lbl)
|
|
var spin = fields[key] as SpinBox; spin.max_value = 1000000; spin.value = entry.get(key, 0); spin.custom_minimum_size.x = 120; grid.add_child(spin)
|
|
vbox.add_child(grid); var btn_vbox = VBoxContainer.new(); btn_vbox.add_theme_constant_override("separation", 5)
|
|
var save_btn = Button.new(); save_btn.text = "UPDATE SCORE"; save_btn.custom_minimum_size.y = 40; btn_vbox.add_child(save_btn)
|
|
var reset_btn = Button.new(); reset_btn.text = "RESET / DELETE RECORD"; reset_btn.add_theme_color_override("font_color", CLR_BTN_DEL); btn_vbox.add_child(reset_btn)
|
|
vbox.add_child(btn_vbox); dialog.add_child(vbox); add_child(dialog); dialog.popup_centered()
|
|
save_btn.pressed.connect(func():
|
|
var stats = {}; for k in fields: stats[k] = int(fields[k].value)
|
|
await _save_score_edit(uid, stats); dialog.queue_free()
|
|
)
|
|
reset_btn.pressed.connect(func(): _confirm_reset_score(uid, name); dialog.queue_free())
|
|
|
|
func _confirm_reset_score(uid: String, name: String) -> void:
|
|
var confirm := ConfirmationDialog.new(); confirm.title = "Reset Score?"; confirm.dialog_text = "Are you sure?"
|
|
add_child(confirm); confirm.popup_centered(); confirm.confirmed.connect(func():
|
|
var res := await _rpc("admin_delete_stats", {"user_id": uid})
|
|
if res.has("success"): await _load_leaderboard()
|
|
)
|
|
|
|
func _save_score_edit(uid: String, stats: Dictionary) -> void:
|
|
_set_status("Updating score...")
|
|
var res := await _rpc("admin_update_stats", {"user_id": uid, "stats": stats})
|
|
if res.has("success"): _set_status("Updated", CLR_STATUS_OK); await _load_leaderboard()
|
|
|
|
func _on_sync_leaderboard() -> void:
|
|
_set_status("Syncing all records to native leaderboard...", CLR_HEADER)
|
|
var res := await _rpc("admin_sync_leaderboard", {})
|
|
if res.has("success"):
|
|
var count = res.get("synced", 0)
|
|
_set_status("Successfully synced %d records!" % count, CLR_STATUS_OK)
|
|
await _load_leaderboard()
|
|
else:
|
|
_set_status("Sync failed: " + str(res.get("error", "Unknown")), CLR_STATUS_ERR)
|
|
|
|
# =============================================================================
|
|
# Common Helpers
|
|
# =============================================================================
|
|
func _clear_tree(tree: Tree, root: TreeItem) -> void:
|
|
if root:
|
|
var child := root.get_first_child()
|
|
while child:
|
|
var next := child.get_next()
|
|
root.remove_child(child)
|
|
child = next
|
|
|
|
func _get_edit_icon() -> Texture2D:
|
|
var img := Image.create(16, 16, false, Image.FORMAT_RGBA8)
|
|
img.fill(Color(0, 0, 0, 0))
|
|
for i in range(4, 12):
|
|
img.set_pixel(i, 11 - i + 4, CLR_TEXT)
|
|
img.set_pixel(i, 12 - i + 4, CLR_TEXT)
|
|
return ImageTexture.create_from_image(img)
|
|
|
|
# =============================================================================
|
|
# TAB 3: DAILY REWARDS
|
|
# =============================================================================
|
|
func _load_daily_rewards_config() -> void:
|
|
_set_status("Loading Daily Rewards Config...")
|
|
var res := await _rpc("get_daily_reward_config_admin", {})
|
|
if res.has("error"):
|
|
_set_status("Failed to load DR config", CLR_STATUS_ERR)
|
|
return
|
|
|
|
var config = res.get("config", {})
|
|
if config.is_empty():
|
|
for m in range(1, 13):
|
|
var m_str = "%02d" % m
|
|
var arr = []
|
|
for d in range(30):
|
|
arr.append({"type": "star", "amount": min(10 + d*5, 100)})
|
|
config[m_str] = arr
|
|
|
|
_daily_reward_config_data = config
|
|
|
|
month_option_btn.clear()
|
|
for i in range(1, 13):
|
|
month_option_btn.add_item(MONTH_NAMES[i - 1])
|
|
month_option_btn.set_item_metadata(i - 1, "%02d" % i)
|
|
|
|
if not _daily_reward_config_data.is_empty():
|
|
_current_dr_month = "01"
|
|
month_option_btn.select(0)
|
|
_build_dr_grid()
|
|
|
|
_update_daily_reward_count()
|
|
_set_status("Config Loaded", CLR_STATUS_OK)
|
|
|
|
func _on_dr_month_selected(index: int) -> void:
|
|
# Save current grid values into the dictionary before switching
|
|
_save_current_grid_to_dict()
|
|
|
|
_current_dr_month = month_option_btn.get_item_metadata(index)
|
|
_build_dr_grid()
|
|
_update_daily_reward_count()
|
|
|
|
func _save_current_grid_to_dict() -> void:
|
|
if _current_dr_month.is_empty(): return
|
|
var arr = []
|
|
for child in days_grid.get_children():
|
|
if child != day_config_template:
|
|
var opt = child.get_node("Margin/VBox/TypeOptionBtn") as OptionButton
|
|
var spin = child.get_node("Margin/VBox/AmountSpinBox") as SpinBox
|
|
var item_type = opt.get_item_text(opt.selected) if opt.selected >= 0 else "star"
|
|
arr.append({"type": item_type, "amount": int(spin.value)})
|
|
if not arr.is_empty():
|
|
_daily_reward_config_data[_current_dr_month] = arr
|
|
|
|
func _build_dr_grid() -> void:
|
|
for child in days_grid.get_children():
|
|
if child != day_config_template:
|
|
child.queue_free()
|
|
|
|
var arr = _daily_reward_config_data.get(_current_dr_month, [])
|
|
for i in range(arr.size()):
|
|
var slot = day_config_template.duplicate()
|
|
slot.visible = true
|
|
days_grid.add_child(slot)
|
|
|
|
var lbl = slot.get_node("Margin/VBox/DayLabel") as Label
|
|
var opt = slot.get_node("Margin/VBox/TypeOptionBtn") as OptionButton
|
|
var spin = slot.get_node("Margin/VBox/AmountSpinBox") as SpinBox
|
|
|
|
lbl.text = "Day " + str(i + 1)
|
|
var rdata = arr[i]
|
|
if typeof(rdata) == TYPE_DICTIONARY:
|
|
spin.value = rdata.get("amount", 0)
|
|
var type_str = rdata.get("type", "star")
|
|
var found = false
|
|
for j in range(opt.item_count):
|
|
if opt.get_item_text(j) == type_str:
|
|
opt.select(j)
|
|
found = true
|
|
break
|
|
if not found:
|
|
opt.add_item(type_str)
|
|
opt.select(opt.item_count - 1)
|
|
else:
|
|
# Fallback for old int format
|
|
spin.value = int(rdata)
|
|
opt.select(0)
|
|
|
|
func _update_daily_reward_count() -> void:
|
|
var rewards = _daily_reward_config_data.get(_current_dr_month, [])
|
|
var count: int = rewards.size() if typeof(rewards) == TYPE_ARRAY else 0
|
|
count_label.text = "%d reward days" % count
|
|
|
|
func _save_daily_rewards_config() -> void:
|
|
_save_current_grid_to_dict()
|
|
_set_status("Saving config...")
|
|
var req = { "config": _daily_reward_config_data }
|
|
var res = await _rpc("set_daily_reward_config", req)
|
|
if res.has("error"):
|
|
_set_status("Save failed: " + res.get("error"), CLR_STATUS_ERR)
|
|
else:
|
|
_set_status("Config saved successfully!", CLR_STATUS_OK)
|
|
|
|
# =============================================================================
|
|
# TAB 4: ANNOUNCEMENTS
|
|
# =============================================================================
|
|
func _on_add_reward_pressed() -> void:
|
|
var row = reward_row_template.duplicate()
|
|
row.visible = true
|
|
rewards_list.add_child(row)
|
|
var remove_btn = row.get_node("RemoveBtn") as Button
|
|
remove_btn.pressed.connect(func(): row.queue_free(); _update_announcement_count())
|
|
_update_announcement_count()
|
|
|
|
func _update_announcement_count() -> void:
|
|
var count := 0
|
|
for child in rewards_list.get_children():
|
|
if child.visible:
|
|
count += 1
|
|
count_label.text = "%d rewards attached" % count
|
|
|
|
func _on_find_user() -> void:
|
|
var input = target_user_edit.text.strip_edges()
|
|
if input.is_empty():
|
|
_resolved_user_id = ""
|
|
resolved_id_label.text = "(Global — all users)"
|
|
return
|
|
|
|
_set_status("Looking up user...")
|
|
var uid = await _resolve_target_user_id(input)
|
|
if uid.is_empty():
|
|
resolved_id_label.text = "NOT FOUND"
|
|
resolved_id_label.add_theme_color_override("font_color", CLR_STATUS_ERR)
|
|
_set_status("User not found: " + input, CLR_STATUS_ERR)
|
|
else:
|
|
_resolved_user_id = uid
|
|
resolved_id_label.text = "ID: " + uid.substr(0, 12) + "..."
|
|
resolved_id_label.add_theme_color_override("font_color", CLR_STATUS_OK)
|
|
_set_status("Found user: " + uid, CLR_STATUS_OK)
|
|
|
|
func _resolve_target_user_id(input: String) -> String:
|
|
"""Resolve a username, display_name, or user_id to a user_id.
|
|
Returns empty string if not found."""
|
|
if input.is_empty():
|
|
return ""
|
|
|
|
# If it looks like a UUID already, return as-is
|
|
if input.length() >= 32 and "-" in input:
|
|
return input
|
|
|
|
# Search in cached all_users first
|
|
for u in all_users:
|
|
var uname: String = u.get("username", "")
|
|
var dname: String = u.get("display_name", "")
|
|
var uid: String = u.get("user_id", "")
|
|
if uname.to_lower() == input.to_lower() or dname.to_lower() == input.to_lower():
|
|
return uid
|
|
|
|
# Cache miss — fetch from server
|
|
var res := await _rpc("admin_list_users", {})
|
|
if res.has("error"):
|
|
return ""
|
|
all_users = res.get("users", [])
|
|
|
|
for u in all_users:
|
|
var uname: String = u.get("username", "")
|
|
var dname: String = u.get("display_name", "")
|
|
var uid: String = u.get("user_id", "")
|
|
if uname.to_lower() == input.to_lower() or dname.to_lower() == input.to_lower():
|
|
return uid
|
|
|
|
return ""
|
|
|
|
func _on_send_mail() -> void:
|
|
var input = target_user_edit.text.strip_edges()
|
|
var title = title_edit.text.strip_edges()
|
|
var content = content_edit.text.strip_edges()
|
|
var start_date = start_date_edit.get_date_iso()
|
|
var end_date = end_date_edit.get_date_iso()
|
|
|
|
if title.is_empty() or content.is_empty():
|
|
_set_status("Title and content cannot be empty", CLR_STATUS_ERR)
|
|
return
|
|
|
|
# Resolve target
|
|
var target_uid := ""
|
|
if not input.is_empty():
|
|
if not _resolved_user_id.is_empty():
|
|
target_uid = _resolved_user_id
|
|
else:
|
|
_set_status("Resolving user...")
|
|
target_uid = await _resolve_target_user_id(input)
|
|
if target_uid.is_empty():
|
|
_set_status("User not found: " + input + ". Use Find button.", CLR_STATUS_ERR)
|
|
return
|
|
_resolved_user_id = target_uid
|
|
resolved_id_label.text = "ID: " + target_uid.substr(0, 12) + "..."
|
|
resolved_id_label.add_theme_color_override("font_color", CLR_STATUS_OK)
|
|
|
|
var rewards_arr = []
|
|
for child in rewards_list.get_children():
|
|
if not child.visible: continue # skip template
|
|
var type_opt = child.get_node("TypeOption") as OptionButton
|
|
var id_edit = child.get_node("IdEdit") as LineEdit
|
|
var amount_spin = child.get_node("AmountSpin") as SpinBox
|
|
|
|
var type_str = type_opt.get_item_text(type_opt.selected)
|
|
var r_id = id_edit.text.strip_edges()
|
|
rewards_arr.append({
|
|
"type": type_str,
|
|
"id": r_id,
|
|
"amount": int(amount_spin.value)
|
|
})
|
|
|
|
send_mail_btn.disabled = true
|
|
_set_status("Sending mail...")
|
|
|
|
var payload = {
|
|
"target_user_id": target_uid,
|
|
"title": title,
|
|
"content": content,
|
|
"start_date": start_date,
|
|
"end_date": end_date,
|
|
"rewards": rewards_arr
|
|
}
|
|
|
|
var res = await _rpc("admin_send_mail", payload)
|
|
send_mail_btn.disabled = false
|
|
|
|
if res.has("error"):
|
|
_set_status("Failed to send mail: " + res.get("error", "Unknown"), CLR_STATUS_ERR)
|
|
else:
|
|
_set_status("Mail sent successfully!", CLR_STATUS_OK)
|
|
target_user_edit.text = ""
|
|
_resolved_user_id = ""
|
|
resolved_id_label.text = ""
|
|
title_edit.text = ""
|
|
content_edit.text = ""
|
|
if start_date_edit.has_method("clear_date"):
|
|
start_date_edit.clear_date()
|
|
if end_date_edit.has_method("clear_date"):
|
|
end_date_edit.clear_date()
|
|
for child in rewards_list.get_children():
|
|
if child.visible: child.queue_free()
|
|
_update_announcement_count()
|
|
|
|
# =============================================================================
|
|
# TAB 5: MAIL MANAGER
|
|
# =============================================================================
|
|
func _load_mail() -> void:
|
|
_clear_tree(mail_tree, _mail_root)
|
|
_set_status("Loading mails...")
|
|
|
|
var res := await _rpc("admin_list_mail", {})
|
|
if res.has("error"):
|
|
_set_status("Failed: " + str(res.error), CLR_STATUS_ERR)
|
|
return
|
|
|
|
var raw_mails = res.get("mails", [])
|
|
_all_server_mails = raw_mails if typeof(raw_mails) == TYPE_ARRAY else []
|
|
count_label.text = "%d mails" % _all_server_mails.size()
|
|
|
|
var now_str = Time.get_datetime_string_from_system(true)
|
|
|
|
for mail in _all_server_mails:
|
|
var item := _mail_root.create_child()
|
|
var mail_type: String = mail.get("type", "global")
|
|
var mail_title: String = mail.get("title", "No Title")
|
|
var sender: String = mail.get("sender", "SYSTEM")
|
|
var start_date: String = mail.get("start_date", "")
|
|
var expiry: String = mail.get("expiry_date", "")
|
|
|
|
# Type column
|
|
item.set_text(0, mail_type.to_upper())
|
|
if mail_type == "personal":
|
|
item.set_custom_color(0, CLR_ADMIN)
|
|
else:
|
|
item.set_custom_color(0, CLR_MOD)
|
|
|
|
# Title
|
|
item.set_text(1, mail_title)
|
|
|
|
# Sender
|
|
item.set_text(2, sender)
|
|
|
|
# Start date
|
|
item.set_text(3, start_date.substr(0, 10) if start_date.length() >= 10 else start_date)
|
|
|
|
# Expiry
|
|
var expiry_short = expiry.substr(0, 10) if expiry.length() >= 10 else expiry
|
|
item.set_text(4, expiry_short)
|
|
|
|
# Status
|
|
var end_date: String = mail.get("end_date", "")
|
|
var status := "ACTIVE"
|
|
if not end_date.is_empty() and now_str > end_date:
|
|
status = "ENDED"
|
|
item.set_custom_color(5, CLR_BTN_DEL)
|
|
elif not expiry.is_empty() and now_str > expiry:
|
|
status = "EXPIRED"
|
|
item.set_custom_color(5, CLR_BTN_DEL)
|
|
else:
|
|
item.set_custom_color(5, CLR_STATUS_OK)
|
|
item.set_text(5, status)
|
|
|
|
item.set_metadata(0, mail)
|
|
|
|
_update_mail_action_btns(null)
|
|
_set_status("")
|
|
|
|
func _on_mail_item_selected() -> void:
|
|
var item = mail_tree.get_selected()
|
|
_update_mail_action_btns(item)
|
|
|
|
func _update_mail_action_btns(item) -> void:
|
|
var has_sel = item != null
|
|
edit_mail_btn.disabled = not has_sel
|
|
end_mail_btn.disabled = not has_sel
|
|
delete_mail_server_btn.disabled = not has_sel
|
|
|
|
func _get_selected_mail() -> Dictionary:
|
|
var item = mail_tree.get_selected()
|
|
if item:
|
|
return item.get_metadata(0)
|
|
return {}
|
|
|
|
func _on_edit_mail_pressed() -> void:
|
|
var mail = _get_selected_mail()
|
|
if mail.is_empty(): return
|
|
_show_edit_mail_dialog(mail)
|
|
|
|
func _show_edit_mail_dialog(mail: Dictionary) -> void:
|
|
var mail_id: String = mail.get("id", "")
|
|
var mail_type: String = mail.get("type", "global")
|
|
var target_uid: String = mail.get("target_user_id", "")
|
|
|
|
var dialog := AcceptDialog.new()
|
|
dialog.title = "Edit Mail: " + mail.get("title", "")
|
|
dialog.min_size = Vector2i(480, 360)
|
|
var vbox := VBoxContainer.new()
|
|
vbox.add_theme_constant_override("separation", 10)
|
|
|
|
var id_lbl := Label.new()
|
|
id_lbl.text = "ID: " + mail_id + " | Type: " + mail_type
|
|
id_lbl.add_theme_color_override("font_color", CLR_DIM)
|
|
vbox.add_child(id_lbl)
|
|
|
|
var grid := GridContainer.new()
|
|
grid.columns = 2
|
|
grid.add_theme_constant_override("h_separation", 8)
|
|
grid.add_theme_constant_override("v_separation", 8)
|
|
|
|
var title_lbl := Label.new(); title_lbl.text = "Title:"; grid.add_child(title_lbl)
|
|
var title_input := LineEdit.new(); title_input.text = mail.get("title", ""); title_input.custom_minimum_size.x = 300; grid.add_child(title_input)
|
|
|
|
var content_lbl := Label.new(); content_lbl.text = "Content:"; grid.add_child(content_lbl)
|
|
var content_input := TextEdit.new(); content_input.text = mail.get("content", ""); content_input.custom_minimum_size = Vector2(300, 100); grid.add_child(content_input)
|
|
|
|
var end_lbl := Label.new(); end_lbl.text = "End Date:"; grid.add_child(end_lbl)
|
|
var end_picker: Button = load("res://scenes/ui/date_picker.tscn").instantiate()
|
|
end_picker.custom_minimum_size.x = 200
|
|
grid.add_child(end_picker)
|
|
# Pre-populate if existing end_date
|
|
var existing_end: String = mail.get("end_date", "")
|
|
if not existing_end.is_empty():
|
|
var parts = existing_end.substr(0, 10).split("-")
|
|
if parts.size() == 3:
|
|
end_picker.current_date = {"year": int(parts[0]), "month": int(parts[1]), "day": int(parts[2])}
|
|
end_picker.view_date = end_picker.current_date.duplicate()
|
|
end_picker.text = existing_end.substr(0, 10)
|
|
|
|
# Recipient row
|
|
var recip_lbl := Label.new(); recip_lbl.text = "Recipient:"; grid.add_child(recip_lbl)
|
|
var recip_hbox := HBoxContainer.new()
|
|
recip_hbox.add_theme_constant_override("separation", 6)
|
|
var recip_input := LineEdit.new()
|
|
recip_input.custom_minimum_size.x = 180
|
|
recip_input.placeholder_text = "Username or ID (empty = global)"
|
|
if mail_type == "personal" and not target_uid.is_empty():
|
|
recip_input.text = target_uid.substr(0, 12) + "..."
|
|
recip_input.tooltip_text = target_uid
|
|
recip_hbox.add_child(recip_input)
|
|
var recip_find_btn := Button.new()
|
|
recip_find_btn.text = "Find"
|
|
recip_find_btn.custom_minimum_size.x = 60
|
|
recip_hbox.add_child(recip_find_btn)
|
|
var recip_resolved_lbl := Label.new()
|
|
recip_resolved_lbl.add_theme_color_override("font_color", CLR_STATUS_OK)
|
|
recip_hbox.add_child(recip_resolved_lbl)
|
|
grid.add_child(recip_hbox)
|
|
|
|
var _edit_resolved_uid := target_uid
|
|
recip_find_btn.pressed.connect(func():
|
|
var inp = recip_input.text.strip_edges()
|
|
if inp.is_empty():
|
|
_edit_resolved_uid = ""
|
|
recip_resolved_lbl.text = "(Global)"
|
|
recip_resolved_lbl.add_theme_color_override("font_color", CLR_STATUS_OK)
|
|
return
|
|
var uid = await _resolve_target_user_id(inp)
|
|
if uid.is_empty():
|
|
recip_resolved_lbl.text = "NOT FOUND"
|
|
recip_resolved_lbl.add_theme_color_override("font_color", CLR_STATUS_ERR)
|
|
else:
|
|
_edit_resolved_uid = uid
|
|
recip_resolved_lbl.text = uid.substr(0, 12) + "..."
|
|
recip_resolved_lbl.add_theme_color_override("font_color", CLR_STATUS_OK)
|
|
)
|
|
|
|
vbox.add_child(grid)
|
|
|
|
var save_btn := Button.new()
|
|
save_btn.text = "Save Changes"
|
|
save_btn.custom_minimum_size.y = 40
|
|
vbox.add_child(save_btn)
|
|
|
|
dialog.add_child(vbox)
|
|
add_child(dialog)
|
|
dialog.popup_centered()
|
|
|
|
save_btn.pressed.connect(func():
|
|
# If user typed something but didn't click Find, auto-resolve
|
|
var recip_text = recip_input.text.strip_edges()
|
|
var final_target = _edit_resolved_uid
|
|
if not recip_text.is_empty() and _edit_resolved_uid == target_uid:
|
|
# Text changed but not resolved yet
|
|
var resolved = await _resolve_target_user_id(recip_text)
|
|
if not resolved.is_empty():
|
|
final_target = resolved
|
|
elif recip_text.is_empty():
|
|
final_target = ""
|
|
|
|
_set_status("Updating mail...")
|
|
var payload = {
|
|
"mail_id": mail_id,
|
|
"type": mail_type,
|
|
"target_user_id": target_uid,
|
|
"new_target_user_id": final_target,
|
|
"title": title_input.text,
|
|
"content": content_input.text,
|
|
"end_date": end_picker.get_date_iso()
|
|
}
|
|
var res = await _rpc("admin_update_mail", payload)
|
|
if res.has("success"):
|
|
_set_status("Mail updated!", CLR_STATUS_OK)
|
|
await _load_mail()
|
|
dialog.queue_free()
|
|
)
|
|
|
|
func _on_end_mail_pressed() -> void:
|
|
var mail = _get_selected_mail()
|
|
if mail.is_empty(): return
|
|
|
|
var mail_id: String = mail.get("id", "")
|
|
var mail_type: String = mail.get("type", "global")
|
|
var target_uid: String = mail.get("target_user_id", "")
|
|
|
|
var confirm := ConfirmationDialog.new()
|
|
confirm.title = "End Mail Now?"
|
|
confirm.dialog_text = "Set end_date to NOW for:\n" + mail.get("title", "Unknown")
|
|
add_child(confirm)
|
|
confirm.popup_centered()
|
|
confirm.confirmed.connect(func():
|
|
var now_iso = Time.get_datetime_string_from_system(true)
|
|
var res = await _rpc("admin_update_mail", {
|
|
"mail_id": mail_id,
|
|
"type": mail_type,
|
|
"target_user_id": target_uid,
|
|
"end_date": now_iso
|
|
})
|
|
if res.has("success"):
|
|
_set_status("Mail ended", CLR_STATUS_OK)
|
|
await _load_mail()
|
|
confirm.queue_free()
|
|
)
|
|
|
|
func _on_delete_mail_server_pressed() -> void:
|
|
var mail = _get_selected_mail()
|
|
if mail.is_empty(): return
|
|
|
|
var mail_id: String = mail.get("id", "")
|
|
var mail_type: String = mail.get("type", "global")
|
|
var target_uid: String = mail.get("target_user_id", "")
|
|
|
|
var confirm := ConfirmationDialog.new()
|
|
confirm.title = "PERMANENTLY Delete Mail?"
|
|
confirm.dialog_text = "Remove from server storage:\n" + mail.get("title", "Unknown") + "\n\nThis cannot be undone!"
|
|
add_child(confirm)
|
|
confirm.popup_centered()
|
|
confirm.confirmed.connect(func():
|
|
var res = await _rpc("admin_delete_mail_server", {
|
|
"mail_id": mail_id,
|
|
"type": mail_type,
|
|
"target_user_id": target_uid
|
|
})
|
|
if res.has("success"):
|
|
_set_status("Mail deleted from server", CLR_STATUS_OK)
|
|
await _load_mail()
|
|
confirm.queue_free()
|
|
)
|
|
|
|
# =============================================================================
|
|
# TAB 6: SHOP — FEATURED BANNERS
|
|
# =============================================================================
|
|
var _slot_nodes: Array = [] # cached references to the 3 slot HBoxContainers
|
|
|
|
func _get_slot_nodes() -> Array:
|
|
if _slot_nodes.is_empty():
|
|
for child in slots_vbox.get_children():
|
|
if child is HBoxContainer:
|
|
_slot_nodes.append(child)
|
|
return _slot_nodes
|
|
|
|
func _load_featured_banners() -> void:
|
|
_set_status("Loading banners...")
|
|
var res := await _rpc("admin_get_featured_banners", {})
|
|
if res.has("error"):
|
|
_set_status("Failed: " + str(res.error), CLR_STATUS_ERR)
|
|
return
|
|
|
|
var raw_banners = res.get("banners", [])
|
|
var banners: Array = raw_banners if typeof(raw_banners) == TYPE_ARRAY else []
|
|
var slots := _get_slot_nodes()
|
|
|
|
for i in range(slots.size()):
|
|
var slot: HBoxContainer = slots[i]
|
|
var id_edit: LineEdit = slot.get_node("ItemIdEdit") as LineEdit
|
|
var lbl_edit: LineEdit = slot.get_node("LabelEdit") as LineEdit
|
|
if i < banners.size():
|
|
var b: Dictionary = banners[i] if banners[i] is Dictionary else {}
|
|
id_edit.text = b.get("item_id", "")
|
|
lbl_edit.text = b.get("label", "")
|
|
else:
|
|
id_edit.text = ""
|
|
lbl_edit.text = ""
|
|
|
|
count_label.text = "%d banners configured" % banners.size()
|
|
_set_status("Banners loaded", CLR_STATUS_OK)
|
|
|
|
func _save_featured_banners() -> void:
|
|
var banners: Array = []
|
|
var slots := _get_slot_nodes()
|
|
|
|
for slot in slots:
|
|
var id_edit: LineEdit = slot.get_node("ItemIdEdit") as LineEdit
|
|
var lbl_edit: LineEdit = slot.get_node("LabelEdit") as LineEdit
|
|
var item_id: String = id_edit.text.strip_edges()
|
|
var label: String = lbl_edit.text.strip_edges()
|
|
if not item_id.is_empty():
|
|
banners.append({"item_id": item_id, "label": label})
|
|
|
|
_set_status("Saving banners...")
|
|
var res := await _rpc("admin_set_featured_banners", {"banners": banners})
|
|
if res.has("error"):
|
|
_set_status("Save failed: " + str(res.error), CLR_STATUS_ERR)
|
|
elif res.has("success"):
|
|
_set_status("Banners saved! (%d slots)" % banners.size(), CLR_STATUS_OK)
|
|
|
|
# =============================================================================
|
|
# TAB 7: LOBBY CHAT
|
|
# =============================================================================
|
|
func _load_chat_config() -> void:
|
|
chat_status_label.text = "Loading config..."
|
|
count_label.text = "chat config"
|
|
var res := await _rpc("admin_get_chat_config", {})
|
|
if res.has("error"):
|
|
chat_status_label.text = "Failed: " + str(res.error)
|
|
return
|
|
|
|
var config: Dictionary = res.get("config", {})
|
|
chat_prefix_edit.text = config.get("prefix", "")
|
|
chat_max_msg_spin.value = config.get("max_messages", 50)
|
|
chat_max_age_spin.value = config.get("max_age_days", 0)
|
|
chat_status_label.text = ""
|
|
|
|
func _on_wipe_chat() -> void:
|
|
var confirm := ConfirmationDialog.new()
|
|
confirm.title = "Wipe Entire Lobby Chat?"
|
|
confirm.dialog_text = "This will delete ALL messages in the global lobby chat for everyone. Continue?"
|
|
add_child(confirm)
|
|
confirm.popup_centered()
|
|
confirm.confirmed.connect(func():
|
|
chat_status_label.text = "Wiping chat..."
|
|
|
|
var target_channel_id = ""
|
|
var lobby = get_tree().get_first_node_in_group("Lobby")
|
|
if lobby and lobby.get("chat") and lobby.chat.get("_chat_channel"):
|
|
target_channel_id = lobby.chat._chat_channel.id
|
|
|
|
if target_channel_id.is_empty():
|
|
chat_status_label.text = "Error: Not connected to global chat."
|
|
confirm.queue_free()
|
|
return
|
|
|
|
var payload = JSON.stringify({"channel_id": target_channel_id})
|
|
var result = await BackendService.admin_clear_global_chat(payload)
|
|
|
|
if result.get("success", false):
|
|
chat_status_label.text = "Chat wiped!"
|
|
if lobby.has_method("admin_wipe_chat"):
|
|
# Just update UI locally
|
|
lobby.chat._chat_messages.clear()
|
|
lobby.chat._refresh_chat_display()
|
|
lobby.chat._inject_local_message("[SYSTEM] : Global chat cleared by admin.")
|
|
else:
|
|
chat_status_label.text = "Wipe failed: " + str(result.get("message", ""))
|
|
|
|
confirm.queue_free()
|
|
)
|
|
|
|
func _on_purge_old_chat() -> void:
|
|
var max_age: int = int(chat_max_age_spin.value)
|
|
if max_age <= 0:
|
|
chat_status_label.text = "Set 'Delete older than' to > 0 days first."
|
|
return
|
|
|
|
var confirm := ConfirmationDialog.new()
|
|
confirm.title = "Purge Old Messages?"
|
|
confirm.dialog_text = "Delete all messages older than %d days?" % max_age
|
|
add_child(confirm)
|
|
confirm.popup_centered()
|
|
confirm.confirmed.connect(func():
|
|
chat_status_label.text = "Purging old messages..."
|
|
|
|
var target_channel_id = ""
|
|
var lobby = get_tree().get_first_node_in_group("Lobby")
|
|
if lobby and lobby.get("chat") and lobby.chat.get("_chat_channel"):
|
|
target_channel_id = lobby.chat._chat_channel.id
|
|
|
|
if target_channel_id.is_empty():
|
|
chat_status_label.text = "Error: Not connected to global chat."
|
|
confirm.queue_free()
|
|
return
|
|
|
|
var result = await BackendService.admin_purge_old_messages(target_channel_id, max_age)
|
|
if result.has("error"):
|
|
chat_status_label.text = "Purge failed: " + str(result.get("error", ""))
|
|
else:
|
|
var deleted: int = result.get("deleted", 0)
|
|
chat_status_label.text = "Purged %d old messages." % deleted
|
|
|
|
confirm.queue_free()
|
|
)
|
|
|
|
func _on_save_chat_config() -> void:
|
|
chat_status_label.text = "Saving..."
|
|
var config := {
|
|
"prefix": chat_prefix_edit.text.strip_edges(),
|
|
"max_messages": int(chat_max_msg_spin.value),
|
|
"max_age_days": int(chat_max_age_spin.value)
|
|
}
|
|
var res := await _rpc("admin_set_chat_config", config)
|
|
if res.has("error"):
|
|
chat_status_label.text = "Failed: " + str(res.error)
|
|
else:
|
|
chat_status_label.text = "Chat config saved!"
|
|
|
|
# =============================================================================
|
|
# TAB 8: CHAT STORAGE
|
|
# =============================================================================
|
|
func _on_load_chat_messages() -> void:
|
|
var channel_id := chat_channel_id_edit.text.strip_edges()
|
|
if channel_id.is_empty():
|
|
# Default to the global lobby room rather than erroring out.
|
|
channel_id = "social_global"
|
|
chat_channel_id_edit.text = channel_id
|
|
|
|
# Best-effort: resolve "social_global" to the real hashed Nakama Channel ID so
|
|
# the admin sees it in the UI. If resolution fails (not in lobby / socket
|
|
# down), fall through with the room name — the server resolves it
|
|
# authoritatively via nk.channel_id_build.
|
|
if channel_id == "social_global":
|
|
var resolved := await _resolve_global_chat_channel_id()
|
|
if not resolved.is_empty():
|
|
channel_id = resolved
|
|
chat_channel_id_edit.text = channel_id # show the admin the real ID
|
|
|
|
_chat_channel_id = channel_id
|
|
_chat_cursor = ""
|
|
_chat_messages_data.clear()
|
|
_clear_tree(chat_tree, _chat_tree_root)
|
|
|
|
await _fetch_chat_messages_batch()
|
|
|
|
func _resolve_global_chat_channel_id() -> String:
|
|
# Nakama Room channel IDs are deterministically hashed from the type and room name.
|
|
# For type=2 (Room) and name="social_global", the ID format is always:
|
|
# "2." + uri_encoded_room_name + "." # no domain needed for rooms.
|
|
# But Nakama's format often just uses "2.RoomName." - let's ensure we try the exact determinism if socket fails.
|
|
|
|
var lobby = get_tree().get_first_node_in_group("Lobby")
|
|
if lobby and lobby.get("chat") and lobby.chat.get("_chat_channel"):
|
|
return lobby.chat._chat_channel.id
|
|
|
|
var socket = NakamaManager.socket
|
|
if socket and socket.is_connected_to_host():
|
|
var result = await socket.join_chat_async("social_global", NakamaSocket.ChannelType.Room, true, false)
|
|
if not result.is_exception():
|
|
return result.id
|
|
|
|
# Fallback if no socket or join failed: construct the exact ID the Web UI expects.
|
|
# Type 2 (Room), Name "social_global"
|
|
return "2." + "social_global".uri_encode() + "."
|
|
|
|
func _on_load_more_chat_messages() -> void:
|
|
if _chat_channel_id.is_empty():
|
|
await _on_load_chat_messages()
|
|
return
|
|
if _chat_cursor.is_empty():
|
|
_set_status("No more messages to load.", CLR_STATUS_OK)
|
|
return
|
|
await _fetch_chat_messages_batch()
|
|
|
|
func _fetch_chat_messages_batch() -> void:
|
|
_set_status("Loading messages...")
|
|
var payload := {
|
|
"channel_id": _chat_channel_id,
|
|
"limit": 50,
|
|
"cursor": _chat_cursor,
|
|
"forward": false
|
|
}
|
|
var res := await _rpc("admin_list_channel_messages", payload)
|
|
|
|
if res.has("error"):
|
|
_set_status("Failed: " + str(res.error), CLR_STATUS_ERR)
|
|
return
|
|
|
|
var msgs = res.get("messages", [])
|
|
var next_cursor = res.get("next_cursor", "")
|
|
if (typeof(msgs) == TYPE_DICTIONARY and msgs.is_empty()) or (typeof(msgs) == TYPE_ARRAY and msgs.is_empty()):
|
|
var fallback = await NakamaManager.client.list_channel_messages_async(NakamaManager.session, _chat_channel_id, 50, false, _chat_cursor)
|
|
if not fallback.is_exception():
|
|
msgs = fallback.messages if fallback.messages else []
|
|
next_cursor = fallback.next_cursor
|
|
else:
|
|
_set_status("Failed: " + fallback.get_exception().message, CLR_STATUS_ERR)
|
|
return
|
|
if typeof(msgs) == TYPE_DICTIONARY:
|
|
msgs = msgs.values()
|
|
elif typeof(msgs) != TYPE_ARRAY:
|
|
msgs = []
|
|
var added_count := 0
|
|
|
|
for raw_msg in msgs:
|
|
var msg := _normalize_chat_storage_message(raw_msg)
|
|
if msg.is_empty():
|
|
continue
|
|
_chat_messages_data.append(msg)
|
|
var item := _chat_tree_root.create_child()
|
|
item.set_cell_mode(0, TreeItem.CELL_MODE_CHECK)
|
|
item.set_editable(0, true)
|
|
item.set_text(1, msg.get("username", msg.get("sender_id", "?").substr(0, 8)))
|
|
item.set_text(2, _format_chat_storage_content(msg.get("content", "")))
|
|
item.set_text(3, msg.get("create_time", "").substr(0, 19).replace("T", " "))
|
|
var mid = msg.get("message_id", "")
|
|
item.set_tooltip_text(3, mid)
|
|
item.set_metadata(0, msg)
|
|
added_count += 1
|
|
|
|
count_label.text = "%d messages loaded" % _chat_messages_data.size()
|
|
chat_tree.queue_redraw()
|
|
|
|
if added_count == 0:
|
|
_set_status("No stored messages returned for channel: " + _chat_channel_id, CLR_STATUS_ERR)
|
|
return
|
|
|
|
if not next_cursor.is_empty():
|
|
_chat_cursor = next_cursor
|
|
_set_status("Loaded page. Click Refresh to load more.", CLR_STATUS_OK)
|
|
else:
|
|
_chat_cursor = ""
|
|
_set_status("All messages loaded.", CLR_STATUS_OK)
|
|
|
|
func _format_chat_storage_content(content) -> String:
|
|
if typeof(content) == TYPE_DICTIONARY:
|
|
return str(content.get("msg", content))
|
|
|
|
var text := str(content)
|
|
var parsed = JSON.parse_string(text)
|
|
if typeof(parsed) == TYPE_DICTIONARY:
|
|
return str(parsed.get("msg", text))
|
|
return text
|
|
|
|
func _normalize_chat_storage_message(raw_msg) -> Dictionary:
|
|
if typeof(raw_msg) == TYPE_DICTIONARY:
|
|
return raw_msg
|
|
if typeof(raw_msg) != TYPE_OBJECT:
|
|
return {}
|
|
return {
|
|
"message_id": raw_msg.message_id,
|
|
"sender_id": raw_msg.sender_id,
|
|
"username": raw_msg.username,
|
|
"content": raw_msg.content,
|
|
"create_time": raw_msg.create_time,
|
|
"update_time": raw_msg.update_time,
|
|
"channel_id": raw_msg.channel_id
|
|
}
|
|
|
|
func _on_delete_selected_chat_messages() -> void:
|
|
var items := _get_checked_chat_items()
|
|
if items.is_empty():
|
|
var selected = chat_tree.get_selected()
|
|
if selected:
|
|
items.append(selected)
|
|
if items.is_empty():
|
|
_set_status("Select one or more messages to delete.", CLR_STATUS_ERR)
|
|
return
|
|
|
|
var confirm := ConfirmationDialog.new()
|
|
confirm.title = "Delete %d Message(s)?" % items.size()
|
|
confirm.dialog_text = "Permanently delete selected chat messages?"
|
|
add_child(confirm)
|
|
confirm.popup_centered()
|
|
confirm.confirmed.connect(func():
|
|
_set_status("Deleting %d message(s)..." % items.size())
|
|
var deleted := 0
|
|
for item in items:
|
|
var msg = item.get_metadata(0)
|
|
if typeof(msg) != TYPE_DICTIONARY:
|
|
continue
|
|
var msg_id = msg.get("message_id", "")
|
|
if msg_id.is_empty():
|
|
continue
|
|
var res = await _rpc("admin_delete_channel_message", {
|
|
"channel_id": _chat_channel_id,
|
|
"message_id": msg_id
|
|
})
|
|
if res.get("success", false):
|
|
deleted += 1
|
|
_chat_messages_data.erase(msg)
|
|
_chat_tree_root.remove_child(item)
|
|
item.free()
|
|
count_label.text = "%d messages loaded" % _chat_messages_data.size()
|
|
_set_status("Deleted %d message(s)" % deleted, CLR_STATUS_OK if deleted > 0 else CLR_STATUS_ERR)
|
|
confirm.queue_free()
|
|
)
|
|
|
|
func _get_checked_chat_items() -> Array:
|
|
var items: Array = []
|
|
var child = _chat_tree_root.get_first_child() if _chat_tree_root else null
|
|
while child:
|
|
if child.is_checked(0):
|
|
items.append(child)
|
|
child = child.get_next()
|
|
return items
|