mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-27 07:58:45 -07:00
Enhance room role management: refactor role granting logic, add role invitation endpoints, and improve role handling in RoomsController and AdminController
This commit is contained in:
@@ -1142,6 +1142,95 @@ internal static class DevMenuProbe
|
|||||||
if (pn.Count > 0) Mod.Log.Msg($" [propnames] {string.Join(", ", pn.GetRange(0, Math.Min(40, pn.Count)))}");
|
if (pn.Count > 0) Mod.Log.Msg($" [propnames] {string.Join(", ", pn.GetRange(0, Math.Min(40, pn.Count)))}");
|
||||||
DumpWatchButtons(t, inst);
|
DumpWatchButtons(t, inst);
|
||||||
DumpCollections(t, inst, "HomeScreenFlow");
|
DumpCollections(t, inst, "HomeScreenFlow");
|
||||||
|
DumpStackedUi(t, inst);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walk HomeScreenFlow -> UIBase (StackedUIBase) and dump the data we need
|
||||||
|
// to build a CUSTOM watch page by cloning a real flow:
|
||||||
|
// * flowResources catalog — the TypeName of every PUSHABLE flow; these
|
||||||
|
// are our clone-target candidates (pick a simple self-contained one).
|
||||||
|
// * the Type->flow cache — what's already instantiated (do NOT mutate
|
||||||
|
// these cached singletons; clone an independent copy instead).
|
||||||
|
// * ScreenHeader presence — proves a cloned flow brings its own title
|
||||||
|
// bar + back button for free.
|
||||||
|
// Names are obfuscated, so we match by SHAPE (array-of-prefab-ref,
|
||||||
|
// Dictionary<Type,flow>) rather than by field name.
|
||||||
|
private static void DumpStackedUi(Type homeType, object homeInst)
|
||||||
|
{
|
||||||
|
var ui = GetMemberValue(homeType, homeInst, "UIBase", "get_UIBase");
|
||||||
|
if (ui is null) { Mod.Log.Msg(" [stackedui] UIBase: null (could not reach StackedUIBase)"); return; }
|
||||||
|
var uiType = ui.GetType();
|
||||||
|
Mod.Log.Msg($" [stackedui] UIBase type={uiType.FullName}");
|
||||||
|
|
||||||
|
var hdr = GetMemberValue(homeType, homeInst, "ScreenHeader", "get_ScreenHeader");
|
||||||
|
if (hdr != null)
|
||||||
|
{
|
||||||
|
var ht = hdr.GetType();
|
||||||
|
var hp = new List<string>(); foreach (var p in ht.GetProperties()) hp.Add(p.Name);
|
||||||
|
Mod.Log.Msg($" [stackedui] home ScreenHeader={ht.Name}; props={string.Join(",", hp.GetRange(0, Math.Min(30, hp.Count)))}");
|
||||||
|
}
|
||||||
|
else Mod.Log.Msg(" [stackedui] home ScreenHeader: null");
|
||||||
|
|
||||||
|
foreach (var f in uiType.GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance))
|
||||||
|
{
|
||||||
|
object? v; try { v = f.GetValue(ui); } catch { continue; }
|
||||||
|
if (v is null) continue;
|
||||||
|
int n = ArrLen(v);
|
||||||
|
// Flow-prefab catalog: a non-empty array whose elements expose a
|
||||||
|
// TypeName string (FlowPrefabResourceReference).
|
||||||
|
if (n > 0 && n <= 300 && (v is Array || v.GetType().Name.IndexOf("Array", StringComparison.OrdinalIgnoreCase) >= 0))
|
||||||
|
{
|
||||||
|
var first = ArrItem(v, 0);
|
||||||
|
var et = first?.GetType().Name ?? "?";
|
||||||
|
if (et.IndexOf("Flow", StringComparison.OrdinalIgnoreCase) < 0 &&
|
||||||
|
et.IndexOf("Prefab", StringComparison.OrdinalIgnoreCase) < 0 &&
|
||||||
|
et.IndexOf("Resource", StringComparison.OrdinalIgnoreCase) < 0 &&
|
||||||
|
et.IndexOf("Reference", StringComparison.OrdinalIgnoreCase) < 0) continue;
|
||||||
|
Mod.Log.Msg($" [stackedui] flow catalog '{f.Name}': {et}[{n}]");
|
||||||
|
for (int i = 0; i < n; i++)
|
||||||
|
{
|
||||||
|
var e = ArrItem(v, i); if (e is null) continue;
|
||||||
|
Mod.Log.Msg($" [{i}] {ReadStringMember(e)}");
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Flow cache: Dictionary<Type, StackedUIFlowBase>.
|
||||||
|
var vt = v.GetType();
|
||||||
|
if (vt.IsGenericType && vt.Name.StartsWith("Dictionary"))
|
||||||
|
{
|
||||||
|
var ga = vt.GetGenericArguments();
|
||||||
|
if (ga.Length == 2 && ga[1].Name.IndexOf("Flow", StringComparison.OrdinalIgnoreCase) >= 0)
|
||||||
|
{
|
||||||
|
var cp = vt.GetProperty("Count");
|
||||||
|
Mod.Log.Msg($" [stackedui] flow cache '{f.Name}': Dictionary<{ga[0].Name},{ga[1].Name}> count={(cp?.GetValue(v) as int? ?? -1)}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Property by name, else parameterless getter method, else null.
|
||||||
|
private static object? GetMemberValue(Type t, object inst, string prop, string getter)
|
||||||
|
{
|
||||||
|
var (found, _, v) = GetMember(t, inst, prop);
|
||||||
|
if (found && v != null) return v;
|
||||||
|
var m = t.GetMethod(getter, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance,
|
||||||
|
null, Type.EmptyTypes, null);
|
||||||
|
if (m != null) { try { return m.Invoke(inst, null); } catch { } }
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
// First non-empty string field/property on an object — for obfuscated
|
||||||
|
// wrappers, that's the TypeName of a FlowPrefabResourceReference.
|
||||||
|
private static string ReadStringMember(object o)
|
||||||
|
{
|
||||||
|
var t = o.GetType();
|
||||||
|
foreach (var p in t.GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance))
|
||||||
|
if (p.PropertyType == typeof(string) && p.GetIndexParameters().Length == 0)
|
||||||
|
{ try { if (p.GetValue(o) is string s && s.Length > 0) return s; } catch { } }
|
||||||
|
foreach (var f in t.GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance))
|
||||||
|
if (f.FieldType == typeof(string))
|
||||||
|
{ try { if (f.GetValue(o) is string s && s.Length > 0) return s; } catch { } }
|
||||||
|
return "(no string member)";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enumerate Button3D components under the HomeScreenFlow via Unity's
|
// Enumerate Button3D components under the HomeScreenFlow via Unity's
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using System.Text.Json;
|
||||||
using DorkNet.Models.Notification;
|
using DorkNet.Models.Notification;
|
||||||
using DorkNet.Server.Auth;
|
using DorkNet.Server.Auth;
|
||||||
using DorkNet.Server.Controllers.Match;
|
using DorkNet.Server.Controllers.Match;
|
||||||
@@ -2476,22 +2477,35 @@ public class RoomsController(
|
|||||||
|
|
||||||
/// <summary>POST <c>rooms/{id}/roles/{playerId}</c> — owner grants
|
/// <summary>POST <c>rooms/{id}/roles/{playerId}</c> — owner grants
|
||||||
/// a role to a player. Auto-accepted (no invite step).</summary>
|
/// a role to a player. Auto-accepted (no invite step).</summary>
|
||||||
|
[HttpPut("rooms/{roomId:long}/roles/{playerId:long}")]
|
||||||
[HttpPost("rooms/{roomId:long}/roles/{playerId:long}")]
|
[HttpPost("rooms/{roomId:long}/roles/{playerId:long}")]
|
||||||
|
[HttpPut("/api/rooms/v1/rooms/{roomId:long}/roles/{playerId:long}")]
|
||||||
|
[HttpPost("/api/rooms/v1/rooms/{roomId:long}/roles/{playerId:long}")]
|
||||||
|
[HttpPut("/api/rooms/v2/rooms/{roomId:long}/roles/{playerId:long}")]
|
||||||
|
[HttpPost("/api/rooms/v2/rooms/{roomId:long}/roles/{playerId:long}")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
public async Task<IActionResult> GrantRole(long roomId, long playerId, [FromBody] GrantRoleRequest req)
|
public async Task<IActionResult> GrantRole(long roomId, long playerId)
|
||||||
{
|
{
|
||||||
var me = this.RequireCurrentPlayerId();
|
var me = this.RequireCurrentPlayerId();
|
||||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
||||||
if (room is null) return NotFound();
|
if (room is null) return NotFound();
|
||||||
if (room.CreatorPlayerId != me) return Forbid();
|
if (!await CanManageRoomRolesAsync(room, me)) return Forbid();
|
||||||
var role = req.Role ?? 0;
|
var role = await ReadClientRoomRoleAsync();
|
||||||
|
if (role is null) return BadRequest(new { error = "missing_role" });
|
||||||
|
if (role.Value == 0)
|
||||||
|
return await ClearRoomAccountRoleAsync(room, playerId);
|
||||||
|
|
||||||
|
if (!TryMapClientRoleToRoomRole(role.Value, out var roomRole))
|
||||||
|
return BadRequest(new { error = "invalid_role", role });
|
||||||
|
|
||||||
var existing = await db.RoomRoles
|
var existing = await db.RoomRoles
|
||||||
.FirstOrDefaultAsync(r => r.RoomId == roomId && r.PlayerId == playerId && r.Role == role);
|
.FirstOrDefaultAsync(r => r.RoomId == roomId && r.PlayerId == playerId && r.Role == roomRole);
|
||||||
if (existing is null)
|
if (existing is null)
|
||||||
{
|
{
|
||||||
|
await RemoveOtherRoomRolesAsync(roomId, playerId, roomRole);
|
||||||
db.RoomRoles.Add(new RoomRoleEntity
|
db.RoomRoles.Add(new RoomRoleEntity
|
||||||
{
|
{
|
||||||
RoomId = roomId, PlayerId = playerId, Role = role,
|
RoomId = roomId, PlayerId = playerId, Role = roomRole,
|
||||||
Accepted = true, GrantedByPlayerId = me,
|
Accepted = true, GrantedByPlayerId = me,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -2500,29 +2514,42 @@ public class RoomsController(
|
|||||||
existing.Accepted = true;
|
existing.Accepted = true;
|
||||||
}
|
}
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
return Ok(new { RoomId = roomId, PlayerId = (int)playerId, Role = role, Accepted = true });
|
return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>POST <c>rooms/{id}/roles/{playerId}/invite</c> — same
|
/// <summary>POST <c>rooms/{id}/roles/{playerId}/invite</c> — same
|
||||||
/// as grant but Accepted=false; the target's accept-invite flow
|
/// as grant but Accepted=false; the target's accept-invite flow
|
||||||
/// flips the flag (separate endpoint, not yet exposed). For now
|
/// flips the flag (separate endpoint, not yet exposed). For now
|
||||||
/// invited rows surface in RoomDetails.InvitedCoOwners etc.</summary>
|
/// invited rows surface in RoomDetails.InvitedCoOwners etc.</summary>
|
||||||
|
[HttpPut("rooms/{roomId:long}/roles/{playerId:long}/invite")]
|
||||||
[HttpPost("rooms/{roomId:long}/roles/{playerId:long}/invite")]
|
[HttpPost("rooms/{roomId:long}/roles/{playerId:long}/invite")]
|
||||||
|
[HttpPut("/api/rooms/v1/rooms/{roomId:long}/roles/{playerId:long}/invite")]
|
||||||
|
[HttpPost("/api/rooms/v1/rooms/{roomId:long}/roles/{playerId:long}/invite")]
|
||||||
|
[HttpPut("/api/rooms/v2/rooms/{roomId:long}/roles/{playerId:long}/invite")]
|
||||||
|
[HttpPost("/api/rooms/v2/rooms/{roomId:long}/roles/{playerId:long}/invite")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
public async Task<IActionResult> InviteRole(long roomId, long playerId, [FromBody] GrantRoleRequest req)
|
public async Task<IActionResult> InviteRole(long roomId, long playerId)
|
||||||
{
|
{
|
||||||
var me = this.RequireCurrentPlayerId();
|
var me = this.RequireCurrentPlayerId();
|
||||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
||||||
if (room is null) return NotFound();
|
if (room is null) return NotFound();
|
||||||
if (room.CreatorPlayerId != me) return Forbid();
|
if (!await CanManageRoomRolesAsync(room, me)) return Forbid();
|
||||||
var role = req.Role ?? 0;
|
var role = await ReadClientRoomRoleAsync();
|
||||||
|
if (role is null) return BadRequest(new { error = "missing_role" });
|
||||||
|
if (role.Value == 0)
|
||||||
|
return await ClearRoomAccountRoleAsync(room, playerId);
|
||||||
|
|
||||||
|
if (!TryMapClientRoleToRoomRole(role.Value, out var roomRole))
|
||||||
|
return BadRequest(new { error = "invalid_role", role });
|
||||||
|
|
||||||
var existing = await db.RoomRoles
|
var existing = await db.RoomRoles
|
||||||
.FirstOrDefaultAsync(r => r.RoomId == roomId && r.PlayerId == playerId && r.Role == role);
|
.FirstOrDefaultAsync(r => r.RoomId == roomId && r.PlayerId == playerId && r.Role == roomRole);
|
||||||
if (existing is null)
|
if (existing is null)
|
||||||
{
|
{
|
||||||
|
await RemoveOtherRoomRolesAsync(roomId, playerId, roomRole);
|
||||||
db.RoomRoles.Add(new RoomRoleEntity
|
db.RoomRoles.Add(new RoomRoleEntity
|
||||||
{
|
{
|
||||||
RoomId = roomId, PlayerId = playerId, Role = role,
|
RoomId = roomId, PlayerId = playerId, Role = roomRole,
|
||||||
Accepted = false, GrantedByPlayerId = me,
|
Accepted = false, GrantedByPlayerId = me,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -2531,7 +2558,97 @@ public class RoomsController(
|
|||||||
existing.Accepted = false;
|
existing.Accepted = false;
|
||||||
}
|
}
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
return Ok(new { RoomId = roomId, PlayerId = (int)playerId, Role = role, Accepted = false });
|
return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IActionResult> ClearRoomAccountRoleAsync(RoomEntity room, long playerId)
|
||||||
|
{
|
||||||
|
if (room.CreatorPlayerId == playerId)
|
||||||
|
return BadRequest(new { error = "cannot_change_creator_role" });
|
||||||
|
|
||||||
|
var rows = await db.RoomRoles
|
||||||
|
.Where(r => r.RoomId == room.Id && r.PlayerId == playerId)
|
||||||
|
.ToListAsync();
|
||||||
|
if (rows.Count > 0)
|
||||||
|
db.RoomRoles.RemoveRange(rows);
|
||||||
|
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return Ok(await BuildRoomServerDetailsWithRolesAsync(room.Id));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<bool> CanManageRoomRolesAsync(RoomEntity room, long playerId)
|
||||||
|
{
|
||||||
|
if (room.CreatorPlayerId == playerId) return true;
|
||||||
|
return await db.RoomRoles.AnyAsync(r =>
|
||||||
|
r.RoomId == room.Id
|
||||||
|
&& r.PlayerId == playerId
|
||||||
|
&& r.Accepted
|
||||||
|
&& r.Role == 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<int?> ReadClientRoomRoleAsync()
|
||||||
|
{
|
||||||
|
int parsed;
|
||||||
|
if (Request.HasFormContentType)
|
||||||
|
{
|
||||||
|
var form = await Request.ReadFormAsync();
|
||||||
|
if (int.TryParse(form["role"].FirstOrDefault(), out parsed)) return parsed;
|
||||||
|
if (int.TryParse(form["Role"].FirstOrDefault(), out parsed)) return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (int.TryParse(Request.Query["role"].FirstOrDefault(), out parsed)) return parsed;
|
||||||
|
if (int.TryParse(Request.Query["Role"].FirstOrDefault(), out parsed)) return parsed;
|
||||||
|
|
||||||
|
if (Request.ContentType?.Contains("json", StringComparison.OrdinalIgnoreCase) == true)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var body = await JsonSerializer.DeserializeAsync<GrantRoleRequest>(
|
||||||
|
Request.Body,
|
||||||
|
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||||
|
if (body?.Role is int bodyRole) return bodyRole;
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RemoveOtherRoomRolesAsync(long roomId, long playerId, int keepRole)
|
||||||
|
{
|
||||||
|
var rows = await db.RoomRoles
|
||||||
|
.Where(r => r.RoomId == roomId && r.PlayerId == playerId && r.Role != keepRole)
|
||||||
|
.ToListAsync();
|
||||||
|
if (rows.Count > 0)
|
||||||
|
db.RoomRoles.RemoveRange(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryMapClientRoleToRoomRole(int clientRole, out int roomRole)
|
||||||
|
{
|
||||||
|
roomRole = clientRole switch
|
||||||
|
{
|
||||||
|
30 => 0, // CoOwner
|
||||||
|
20 => 1, // Moderator
|
||||||
|
10 => 2, // Host
|
||||||
|
_ => -1,
|
||||||
|
};
|
||||||
|
return roomRole >= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<object> BuildRoomServerDetailsWithRolesAsync(long roomId)
|
||||||
|
{
|
||||||
|
var room = await db.Rooms.FirstAsync(r => r.Id == roomId);
|
||||||
|
var scenes = await db.RoomScenes
|
||||||
|
.Where(s => s.RoomId == roomId)
|
||||||
|
.OrderBy(s => s.OrderIndex)
|
||||||
|
.ToListAsync();
|
||||||
|
var roles = await db.RoomRoles
|
||||||
|
.Where(r => r.RoomId == roomId)
|
||||||
|
.ToListAsync();
|
||||||
|
return BuildRoomServerDetails(room, scenes, roles: roles);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -350,6 +350,7 @@ public class AdminController(
|
|||||||
{
|
{
|
||||||
r.Id,
|
r.Id,
|
||||||
r.Name,
|
r.Name,
|
||||||
|
r.ImageName,
|
||||||
r.IsAGRoom,
|
r.IsAGRoom,
|
||||||
r.IsDormRoom,
|
r.IsDormRoom,
|
||||||
r.CreatorPlayerId,
|
r.CreatorPlayerId,
|
||||||
@@ -1032,6 +1033,12 @@ public class AdminController(
|
|||||||
if (body.PlayerId <= 0) return BadRequest("missing player");
|
if (body.PlayerId <= 0) return BadRequest("missing player");
|
||||||
if (body.Role is < 0 or > 2) return BadRequest("invalid role");
|
if (body.Role is < 0 or > 2) return BadRequest("invalid role");
|
||||||
|
|
||||||
|
var oldRoles = await db.RoomRoles
|
||||||
|
.Where(r => r.RoomId == id && r.PlayerId == body.PlayerId && r.Role != body.Role)
|
||||||
|
.ToListAsync();
|
||||||
|
if (oldRoles.Count > 0)
|
||||||
|
db.RoomRoles.RemoveRange(oldRoles);
|
||||||
|
|
||||||
var existing = await db.RoomRoles.FirstOrDefaultAsync(r =>
|
var existing = await db.RoomRoles.FirstOrDefaultAsync(r =>
|
||||||
r.RoomId == id && r.PlayerId == body.PlayerId && r.Role == body.Role);
|
r.RoomId == id && r.PlayerId == body.PlayerId && r.Role == body.Role);
|
||||||
if (existing is null)
|
if (existing is null)
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ function subdomainUrl(subdomain: string, path: string | null | undefined, query?
|
|||||||
export interface Room {
|
export interface Room {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
imageName: string | null;
|
||||||
isAGRoom: boolean;
|
isAGRoom: boolean;
|
||||||
isDormRoom: boolean;
|
isDormRoom: boolean;
|
||||||
creatorPlayerId: number;
|
creatorPlayerId: number;
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||||
import { api } from '../lib/api';
|
import { api } from '../lib/api';
|
||||||
import type { RoomDetail, RoomInstance, RoomRoleGrant } from '../lib/types';
|
import type { Player, RoomDetail, RoomInstance, RoomRoleGrant } from '../lib/types';
|
||||||
import { profileImageUrl } from '../lib/types';
|
import { profileImageUrl } from '../lib/types';
|
||||||
import { useApi } from '../lib/useApi';
|
import { useApi } from '../lib/useApi';
|
||||||
import { PageHeader } from '../components/PageHeader';
|
import { PageHeader } from '../components/PageHeader';
|
||||||
import { Empty } from '../components/Empty';
|
import { Empty } from '../components/Empty';
|
||||||
import { Modal } from '../components/Modal';
|
import { Modal } from '../components/Modal';
|
||||||
import { Confirm } from '../components/Confirm';
|
import { Confirm } from '../components/Confirm';
|
||||||
|
import { PlayerAvatar } from '../components/PlayerAvatar';
|
||||||
import { useToast } from '../components/Toast';
|
import { useToast } from '../components/Toast';
|
||||||
import { ArrowLeft, RefreshCw, Trash } from '../components/Icons';
|
import { ArrowLeft, RefreshCw, Trash } from '../components/Icons';
|
||||||
|
|
||||||
@@ -367,15 +368,40 @@ function PlayerRow({ player }: { player: { id: number; username: string; display
|
|||||||
function AddRoleModal({ roomId, role, onClose, onAdded }: {
|
function AddRoleModal({ roomId, role, onClose, onAdded }: {
|
||||||
roomId: number; role: 0 | 1 | 2; onClose: () => void; onAdded: () => void;
|
roomId: number; role: 0 | 1 | 2; onClose: () => void; onAdded: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [playerIdStr, setPlayerIdStr] = useState('');
|
const { data: players, loading: playersLoading, error: playersError } = useApi<Player[]>('/players?take=500');
|
||||||
|
const [playerQuery, setPlayerQuery] = useState('');
|
||||||
|
const [selectedPlayerId, setSelectedPlayerId] = useState('');
|
||||||
const [accepted, setAccepted] = useState(true);
|
const [accepted, setAccepted] = useState(true);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
|
||||||
|
const matchingPlayers = useMemo(() => {
|
||||||
|
const q = playerQuery.trim().toLowerCase();
|
||||||
|
const source = players ?? [];
|
||||||
|
if (!q) return source;
|
||||||
|
return source.filter(p =>
|
||||||
|
p.id.toString().includes(q) ||
|
||||||
|
p.username.toLowerCase().includes(q) ||
|
||||||
|
(p.displayName ?? '').toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
}, [playerQuery, players]);
|
||||||
|
|
||||||
|
const selectedPlayer = useMemo(
|
||||||
|
() => (players ?? []).find(p => p.id === Number(selectedPlayerId)) ?? null,
|
||||||
|
[players, selectedPlayerId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const playerLabel = (p: Player) => {
|
||||||
|
const handle = p.username ? `@${p.username}` : `#${p.id}`;
|
||||||
|
return p.displayName && p.displayName !== p.username
|
||||||
|
? `${handle} - ${p.displayName} - #${p.id}`
|
||||||
|
: `${handle} - #${p.id}`;
|
||||||
|
};
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
const pid = Number(playerIdStr);
|
const pid = Number(selectedPlayerId);
|
||||||
if (!Number.isFinite(pid) || pid <= 0) {
|
if (!Number.isFinite(pid) || pid <= 0) {
|
||||||
toast.push('Enter a numeric player id', 'error');
|
toast.push('Select a player', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
@@ -384,7 +410,7 @@ function AddRoleModal({ roomId, role, onClose, onAdded }: {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: { PlayerId: pid, Role: role, Accepted: accepted },
|
body: { PlayerId: pid, Role: role, Accepted: accepted },
|
||||||
});
|
});
|
||||||
toast.push(`Granted ${ROLE_LABEL[role]} to #${pid}`, 'success');
|
toast.push(`Granted ${ROLE_LABEL[role]} to ${selectedPlayer ? playerLabel(selectedPlayer) : `#${pid}`}`, 'success');
|
||||||
onAdded();
|
onAdded();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.push((e as Error).message, 'error');
|
toast.push((e as Error).message, 'error');
|
||||||
@@ -400,22 +426,50 @@ function AddRoleModal({ roomId, role, onClose, onAdded }: {
|
|||||||
title={`Add ${ROLE_LABEL[role].toLowerCase()}`}
|
title={`Add ${ROLE_LABEL[role].toLowerCase()}`}
|
||||||
footer={<>
|
footer={<>
|
||||||
<button onClick={onClose} className="btn-ghost text-xs" disabled={busy}>Cancel</button>
|
<button onClick={onClose} className="btn-ghost text-xs" disabled={busy}>Cancel</button>
|
||||||
<button onClick={submit} className="btn-primary text-xs" disabled={busy}>
|
<button onClick={submit} className="btn-primary text-xs" disabled={busy || !selectedPlayerId}>
|
||||||
{busy ? 'Adding…' : 'Add'}
|
{busy ? 'Adding…' : 'Add'}
|
||||||
</button>
|
</button>
|
||||||
</>}
|
</>}
|
||||||
>
|
>
|
||||||
<div className="space-y-3 text-sm">
|
<div className="space-y-3 text-sm">
|
||||||
<label className="flex flex-col gap-1">
|
<label className="flex flex-col gap-1">
|
||||||
<span className="label">Player id</span>
|
<span className="label">Search players</span>
|
||||||
<input
|
<input
|
||||||
value={playerIdStr}
|
value={playerQuery}
|
||||||
onChange={e => setPlayerIdStr(e.target.value)}
|
onChange={e => setPlayerQuery(e.target.value)}
|
||||||
className="input font-mono"
|
className="input"
|
||||||
placeholder="1362428"
|
placeholder="Username, display name, or id"
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<span className="label">Player</span>
|
||||||
|
<select
|
||||||
|
value={selectedPlayerId}
|
||||||
|
onChange={e => setSelectedPlayerId(e.target.value)}
|
||||||
|
className="input"
|
||||||
|
disabled={playersLoading}
|
||||||
|
>
|
||||||
|
<option value="">{playersLoading ? 'Loading players…' : 'Select a player'}</option>
|
||||||
|
{matchingPlayers.map(p => (
|
||||||
|
<option key={p.id} value={p.id}>{playerLabel(p)}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{playersError && <div className="text-xs text-danger">{playersError}</div>}
|
||||||
|
{selectedPlayer && (
|
||||||
|
<div className="flex items-center gap-2 rounded-md border border-ink-800 bg-ink-900/50 px-2.5 py-2">
|
||||||
|
<PlayerAvatar
|
||||||
|
name={selectedPlayer.profileImageName}
|
||||||
|
displayName={selectedPlayer.displayName || selectedPlayer.username}
|
||||||
|
size={32}
|
||||||
|
/>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-ink-100">{selectedPlayer.username ? `@${selectedPlayer.username}` : `#${selectedPlayer.id}`}</div>
|
||||||
|
<div className="truncate text-xs text-ink-400">{selectedPlayer.displayName || `Player #${selectedPlayer.id}`}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<label className="flex items-center gap-2 text-xs">
|
<label className="flex items-center gap-2 text-xs">
|
||||||
<input type="checkbox" checked={accepted} onChange={e => setAccepted(e.target.checked)} />
|
<input type="checkbox" checked={accepted} onChange={e => setAccepted(e.target.checked)} />
|
||||||
<span>Mark as accepted (otherwise shows as pending invite)</span>
|
<span>Mark as accepted (otherwise shows as pending invite)</span>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { api } from '../lib/api';
|
import { api } from '../lib/api';
|
||||||
import type { Room } from '../lib/types';
|
import { imageCdnUrl, type Room } from '../lib/types';
|
||||||
import { useApi } from '../lib/useApi';
|
import { useApi } from '../lib/useApi';
|
||||||
import { PageHeader } from '../components/PageHeader';
|
import { PageHeader } from '../components/PageHeader';
|
||||||
import { Empty } from '../components/Empty';
|
import { Empty } from '../components/Empty';
|
||||||
@@ -129,9 +129,10 @@ export function Rooms() {
|
|||||||
{error && <div className="px-4 py-3 text-sm text-danger">{error}</div>}
|
{error && <div className="px-4 py-3 text-sm text-danger">{error}</div>}
|
||||||
{data && filtered.length === 0 && <Empty title="No rooms match" />}
|
{data && filtered.length === 0 && <Empty title="No rooms match" />}
|
||||||
{filtered.length > 0 && (
|
{filtered.length > 0 && (
|
||||||
<div className="table-scroll"><table className="w-full text-sm min-w-[640px]">
|
<div className="table-scroll"><table className="w-full text-sm min-w-[760px]">
|
||||||
<thead className="text-[11px] uppercase tracking-wider text-ink-400 bg-ink-900/50 border-b border-ink-800">
|
<thead className="text-[11px] uppercase tracking-wider text-ink-400 bg-ink-900/50 border-b border-ink-800">
|
||||||
<tr>
|
<tr>
|
||||||
|
<th className="text-left font-medium px-4 py-2.5">Image</th>
|
||||||
<th className="text-left font-medium px-4 py-2.5">#</th>
|
<th className="text-left font-medium px-4 py-2.5">#</th>
|
||||||
<th className="text-left font-medium px-4 py-2.5">Name</th>
|
<th className="text-left font-medium px-4 py-2.5">Name</th>
|
||||||
<th className="text-left font-medium px-4 py-2.5">Type</th>
|
<th className="text-left font-medium px-4 py-2.5">Type</th>
|
||||||
@@ -143,6 +144,9 @@ export function Rooms() {
|
|||||||
<tbody className="divide-y divide-ink-800">
|
<tbody className="divide-y divide-ink-800">
|
||||||
{filtered.map(r => (
|
{filtered.map(r => (
|
||||||
<tr key={r.id} className="table-row-hover">
|
<tr key={r.id} className="table-row-hover">
|
||||||
|
<td className="px-4 py-2.5">
|
||||||
|
<RoomThumb room={r} />
|
||||||
|
</td>
|
||||||
<td className="px-4 py-2.5 text-ink-400 tabular-nums">{r.id}</td>
|
<td className="px-4 py-2.5 text-ink-400 tabular-nums">{r.id}</td>
|
||||||
<td className="px-4 py-2.5 font-medium">
|
<td className="px-4 py-2.5 font-medium">
|
||||||
{/* Whole row's primary action is "open detail view"
|
{/* Whole row's primary action is "open detail view"
|
||||||
@@ -199,6 +203,35 @@ export function Rooms() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function RoomThumb({ room }: { room: Room }) {
|
||||||
|
const [errored, setErrored] = useState(false);
|
||||||
|
const url = room.imageName && !errored
|
||||||
|
? imageCdnUrl(room.imageName, 'width=180&sig=p1')
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to={`/rooms/${room.id}`}
|
||||||
|
className="block h-12 w-20 overflow-hidden rounded-md border border-ink-800 bg-ink-950"
|
||||||
|
title={room.name}
|
||||||
|
>
|
||||||
|
{url ? (
|
||||||
|
<img
|
||||||
|
src={url}
|
||||||
|
alt={`${room.name} thumbnail`}
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
loading="lazy"
|
||||||
|
onError={() => setErrored(true)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full w-full items-center justify-center bg-ink-900 text-[10px] uppercase tracking-wide text-ink-500">
|
||||||
|
No image
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function PlayMenuTagsPanel({
|
function PlayMenuTagsPanel({
|
||||||
tags,
|
tags,
|
||||||
loading,
|
loading,
|
||||||
|
|||||||
Reference in New Issue
Block a user