mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 23:18:44 -07:00
Implement Play Menu Tags management: add endpoints for retrieving and updating tags; enhance database schema and UI integration
This commit is contained in:
@@ -33,6 +33,7 @@ public class RoomsController(
|
||||
OnlinePresenceService onlinePresence,
|
||||
DomainConfig domain,
|
||||
NotificationService notifications,
|
||||
ServerSettingsService serverSettings,
|
||||
ILogger<RoomsController> logger) : ControllerBase
|
||||
{
|
||||
private long? CurrentPlayerId => ControllerBaseExtensions.CurrentPlayerId(this);
|
||||
@@ -143,23 +144,30 @@ public class RoomsController(
|
||||
// '#' at render time, so sending "#community" produces "##community" in
|
||||
// the UI. Names stored in the DB also drop the prefix.
|
||||
[HttpGet("api/rooms/v1/filters")]
|
||||
public IActionResult Filters() => Ok(new
|
||||
public async Task<IActionResult> Filters()
|
||||
{
|
||||
PinnedFilters = new[] { "community", "recroomoriginal", "featured", "quest" },
|
||||
PopularFilters = new[] { "paintball", "dodgeball", "soccer", "lasertag", "discgolf" },
|
||||
});
|
||||
var tags = await serverSettings.GetPlayMenuTagsAsync();
|
||||
return Ok(new
|
||||
{
|
||||
PinnedFilters = tags.PinnedTags,
|
||||
PopularFilters = tags.PopularTags,
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("api/rooms/v1/tags")]
|
||||
public IActionResult Tags() => Ok(new[] {
|
||||
"community", "recroomoriginal", "featured", "quest",
|
||||
"paintball", "dodgeball", "soccer", "lasertag", "discgolf",
|
||||
});
|
||||
public async Task<IActionResult> Tags()
|
||||
{
|
||||
var tags = await serverSettings.GetPlayMenuTagsAsync();
|
||||
return Ok(tags.PinnedTags.Concat(tags.PopularTags).Distinct(StringComparer.OrdinalIgnoreCase).ToArray());
|
||||
}
|
||||
|
||||
[HttpGet("api/rooms/v1/pinnedtags")]
|
||||
public IActionResult PinnedTags() => Ok(new[] { "community", "recroomoriginal", "featured" });
|
||||
public async Task<IActionResult> PinnedTags()
|
||||
=> Ok((await serverSettings.GetPlayMenuTagsAsync()).PinnedTags);
|
||||
|
||||
[HttpGet("api/rooms/v1/populartags")]
|
||||
public IActionResult PopularTags() => Ok(new[] { "paintball", "dodgeball", "soccer" });
|
||||
public async Task<IActionResult> PopularTags()
|
||||
=> Ok((await serverSettings.GetPlayMenuTagsAsync()).PopularTags);
|
||||
|
||||
// ── Single room lookups ──────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -1494,6 +1494,26 @@ public class AdminController(
|
||||
return Ok(weekly);
|
||||
}
|
||||
|
||||
[HttpGet("settings/play-menu-tags")]
|
||||
public async Task<ActionResult> GetPlayMenuTags()
|
||||
=> Ok(await serverSettings.GetPlayMenuTagsAsync());
|
||||
|
||||
public sealed record PlayMenuTagsRequest(
|
||||
List<string>? PinnedTags,
|
||||
List<string>? PopularTags);
|
||||
|
||||
[HttpPost("settings/play-menu-tags")]
|
||||
public async Task<ActionResult> SetPlayMenuTags([FromBody] PlayMenuTagsRequest body)
|
||||
{
|
||||
var tags = await serverSettings.SetPlayMenuTagsAsync(
|
||||
body.PinnedTags ?? [],
|
||||
body.PopularTags ?? []);
|
||||
await LogAsync("play_menu_tags_updated", "system", 0,
|
||||
$"pinned={string.Join(",", tags.PinnedTags)} popular={string.Join(",", tags.PopularTags)}");
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(tags);
|
||||
}
|
||||
|
||||
// ── Signup codes ─────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>GET <c>api/admin/v1/signup-codes</c> — every issued code
|
||||
|
||||
@@ -609,6 +609,7 @@ public class DorkNetDbContext(DbContextOptions<DorkNetDbContext> options) : DbCo
|
||||
e.Property(s => s.WeeklyChallengesCompletedRequired).HasDefaultValue(true);
|
||||
e.Property(s => s.WeeklyChallengesJson).HasDefaultValue(string.Empty);
|
||||
e.Property(s => s.WeeklyChallengeRewardJson).HasDefaultValue(string.Empty);
|
||||
e.Property(s => s.PlayMenuTagsJson).HasDefaultValue(string.Empty);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<RoomKeyEntity>(e =>
|
||||
|
||||
@@ -38,5 +38,10 @@ public class ServerSettingsEntity
|
||||
/// server-side XP/token grant.</summary>
|
||||
public string WeeklyChallengeRewardJson { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Admin-managed Play menu room filter chips as JSON. Stores
|
||||
/// separate pinned and popular tag lists; wire endpoints serve them to
|
||||
/// the watch without leading # characters.</summary>
|
||||
public string PlayMenuTagsJson { get; set; } = string.Empty;
|
||||
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
@@ -41,6 +41,19 @@ public class ConfigService(IConfiguration config, DomainConfig domain)
|
||||
["FriendsEnabled"] = "true",
|
||||
["ChatEnabled"] = "true",
|
||||
["VoiceEnabled"] = "true",
|
||||
// Rec Center category doors are baked with these
|
||||
// config lookups. Missing values leave the door browser
|
||||
// and door-specific return spawns without a category.
|
||||
["Door.Shooters.Title"] = "Shooters",
|
||||
["Door.Shooters.Query"] = "#paintball,#lasertag,#recroyale",
|
||||
["Door.Creative.Title"] = "Creative",
|
||||
["Door.Creative.Query"] = "#creative,#makerpen,#template",
|
||||
["Door.Quests.Title"] = "Quests",
|
||||
["Door.Quests.Query"] = "#quest",
|
||||
["Door.Sports.Title"] = "Sports",
|
||||
["Door.Sports.Query"] = "#sport",
|
||||
["Door.Featured.Title"] = "Featured",
|
||||
["Door.Featured.Query"] = "#featured",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -77,50 +77,50 @@ public class RoomService(DorkNetDbContext db)
|
||||
// tools/extract-locations-binary.py. Output cached at data/room_locations.json.
|
||||
// Each Location has a SceneName matching one of the .unity scenes the client
|
||||
// ships (see tools/dump-all-scenes.py for the 96-scene index).
|
||||
("RecCenter", "Rec Center", "A social hub to meet and mingle with friends new and old.", "cbad71af-0831-44d8-b8ef-69edafa841f6", "recroomoriginal,featured", "image_RecCenter.png"),
|
||||
("3DCharades", "3D Charades", "Take turns drawing, acting, and guessing funny phrases with your friends!", "4078dfed-24bb-4db7-863f-578ba48d726b", "recroomoriginal", "image_3DCharades.png"),
|
||||
("DiscGolfLake", "DiscGolf Lake", "A leisurely stroll through the grass. Throw your disc into the goal.", "f6f7256c-e438-4299-b99e-d20bef8cf7e0", "recroomoriginal,sport", "image_DiscGolfLake.png"),
|
||||
("DiscGolfPropulsion","DiscGolf Propulsion","Throw your disc through hazards and around wind machines on this challenging course!", "d9378c9f-80bc-46fb-ad1e-1bed8a674f55", "recroomoriginal,sport", "image_DiscGolfPropulsion.png"),
|
||||
("Dodgeball", "Dodgeball", "Throw dodgeballs to knock out your friends in this gym classic!", "3d474b26-26f7-45e9-9a36-9b02847d5e6f", "recroomoriginal,sport", "image_Dodgeball.png"),
|
||||
("Paddleball", "Paddleball", "A simple rally game between two players in a plexiglass tube with a zero-g ball.", "d89f74fa-d51e-477a-a425-025a891dd499", "recroomoriginal,sport", "image_Paddleball.png"),
|
||||
("Paintball", "Paintball", "Red and Blue teams splat each other in capture the flag and team battle.", "e122fe98-e7db-49e8-a1b1-105424b6e1f0", "recroomoriginal,sport", "image_Paintball.png"),
|
||||
("RecCenter", "Rec Center", "A social hub to meet and mingle with friends new and old.", "cbad71af-0831-44d8-b8ef-69edafa841f6", "recroomoriginal,featured,hangout,chill,social", "image_RecCenter.png"),
|
||||
("3DCharades", "3D Charades", "Take turns drawing, acting, and guessing funny phrases with your friends!", "4078dfed-24bb-4db7-863f-578ba48d726b", "recroomoriginal,charades,party,creative", "image_3DCharades.png"),
|
||||
("DiscGolfLake", "DiscGolf Lake", "A leisurely stroll through the grass. Throw your disc into the goal.", "f6f7256c-e438-4299-b99e-d20bef8cf7e0", "recroomoriginal,sport,discgolf", "image_DiscGolfLake.png"),
|
||||
("DiscGolfPropulsion","DiscGolf Propulsion","Throw your disc through hazards and around wind machines on this challenging course!", "d9378c9f-80bc-46fb-ad1e-1bed8a674f55", "recroomoriginal,sport,discgolf", "image_DiscGolfPropulsion.png"),
|
||||
("Dodgeball", "Dodgeball", "Throw dodgeballs to knock out your friends in this gym classic!", "3d474b26-26f7-45e9-9a36-9b02847d5e6f", "recroomoriginal,sport,dodgeball,pvp", "image_Dodgeball.png"),
|
||||
("Paddleball", "Paddleball", "A simple rally game between two players in a plexiglass tube with a zero-g ball.", "d89f74fa-d51e-477a-a425-025a891dd499", "recroomoriginal,sport,paddleball,pvp", "image_Paddleball.png"),
|
||||
("Paintball", "Paintball", "Red and Blue teams splat each other in capture the flag and team battle.", "e122fe98-e7db-49e8-a1b1-105424b6e1f0", "recroomoriginal,sport,paintball,pvp", "image_Paintball.png"),
|
||||
// Soccer scene = soccer.unity, Location ReplicationId verified
|
||||
// (was previously mis-mapped to IsleOfLostSkulls' GUID).
|
||||
("Soccer", "Soccer", "Teams of three run around slamming themselves into an over-sized soccer ball. Goal!", "6d5eea4b-f069-4ed0-9916-0e2f07df0d03", "recroomoriginal,sport", "image_Soccer.png"),
|
||||
("Soccer", "Soccer", "Teams of three run around slamming themselves into an over-sized soccer ball. Goal!", "6d5eea4b-f069-4ed0-9916-0e2f07df0d03", "recroomoriginal,sport,soccer,pvp", "image_Soccer.png"),
|
||||
// LaserTag in the 2020 build IS the CyberJunkCity arena scene
|
||||
// (Arena_Cyberjunk_City.unity). Was previously mis-mapped to
|
||||
// soccer's GUID. Hangar (Arena_Hangar3.unity) is the alternate
|
||||
// map players reach via the in-game lobby.
|
||||
("LaserTag", "Laser Tag", "Teams battle each other and waves of robots in a totally cyber neon future city.", "9d6456ce-6264-48b4-808d-2d96b3d91038", "recroomoriginal,sport", "image_LaserTag.png"),
|
||||
("CyberJunkCity", "Laser Tag CyberJunk","Teams battle each other and waves of robots in a totally cyber neon future city.", "9d6456ce-6264-48b4-808d-2d96b3d91038", "recroomoriginal,sport", "image_CyberJunkCity.png"),
|
||||
("LaserTagHangar", "Laser Tag Hangar", "Teams battle in an industrial warehouse map.", "239e676c-f12f-489f-bf3a-d4c383d692c3", "recroomoriginal,sport", "image_LaserTag.png"),
|
||||
("RecRoyaleSquads", "Rec Royale Squads", "Squads of three battle it out on Frontier Island. Last squad standing wins!", "253fa009-6e65-4c90-91a1-7137a56a267f", "recroomoriginal,featured,sport", "image_RecRoyaleSquads.png"),
|
||||
("LaserTag", "Laser Tag", "Teams battle each other and waves of robots in a totally cyber neon future city.", "9d6456ce-6264-48b4-808d-2d96b3d91038", "recroomoriginal,sport,lasertag,pvp", "image_LaserTag.png"),
|
||||
("CyberJunkCity", "Laser Tag CyberJunk","Teams battle each other and waves of robots in a totally cyber neon future city.", "9d6456ce-6264-48b4-808d-2d96b3d91038", "recroomoriginal,sport,lasertag,pvp", "image_CyberJunkCity.png"),
|
||||
("LaserTagHangar", "Laser Tag Hangar", "Teams battle in an industrial warehouse map.", "239e676c-f12f-489f-bf3a-d4c383d692c3", "recroomoriginal,sport,lasertag,pvp", "image_LaserTag.png"),
|
||||
("RecRoyaleSquads", "Rec Royale Squads", "Squads of three battle it out on Frontier Island. Last squad standing wins!", "253fa009-6e65-4c90-91a1-7137a56a267f", "recroomoriginal,featured,sport,recroyale,pvp,battle", "image_RecRoyaleSquads.png"),
|
||||
// RecRoyaleSolos was previously pointing at a Home location
|
||||
// GUID (85b43509-…); the real Solos Location is b010171f-….
|
||||
("RecRoyaleSolos", "Rec Royale Solos", "Battle it out on Frontier Island. Last person standing wins!", "b010171f-4875-4e89-baba-61e878cd41e1", "recroomoriginal,sport", "image_RecRoyaleSolos.png"),
|
||||
("GoldenTrophy", "Quest For The Golden Trophy", "The goblin king stole Coach's Golden Trophy. Team up and embark on an epic quest to recover it!", "91e16e35-f48f-4700-ab8a-a1b79e50e51b", "recroomoriginal,quest", "image_GoldenTrophy.png"),
|
||||
("TheRiseofJumbotron","The Rise of Jumbotron", "Robot invaders threaten the galaxy! Team up with your friends and bring the laser heat!", "acc06e66-c2d0-4361-b0cd-46246a4c455c", "recroomoriginal,quest", "image_TheRiseofJumbotron.png"),
|
||||
("CrimsonCauldron", "Curse of the Crimson Cauldron", "Can your band of adventurers brave the enchanted wilds, and lift the curse of the crimson cauldron?", "949fa41f-4347-45c0-b7ac-489129174045", "recroomoriginal,quest", "image_CrimsonCauldron.png"),
|
||||
("RecRoyaleSolos", "Rec Royale Solos", "Battle it out on Frontier Island. Last person standing wins!", "b010171f-4875-4e89-baba-61e878cd41e1", "recroomoriginal,sport,recroyale,pvp,battle", "image_RecRoyaleSolos.png"),
|
||||
("GoldenTrophy", "Quest For The Golden Trophy", "The goblin king stole Coach's Golden Trophy. Team up and embark on an epic quest to recover it!", "91e16e35-f48f-4700-ab8a-a1b79e50e51b", "recroomoriginal,quest,co-op,adventure", "image_GoldenTrophy.png"),
|
||||
("TheRiseofJumbotron","The Rise of Jumbotron", "Robot invaders threaten the galaxy! Team up with your friends and bring the laser heat!", "acc06e66-c2d0-4361-b0cd-46246a4c455c", "recroomoriginal,quest,co-op,adventure", "image_TheRiseofJumbotron.png"),
|
||||
("CrimsonCauldron", "Curse of the Crimson Cauldron", "Can your band of adventurers brave the enchanted wilds, and lift the curse of the crimson cauldron?", "949fa41f-4347-45c0-b7ac-489129174045", "recroomoriginal,quest,co-op,adventure", "image_CrimsonCauldron.png"),
|
||||
// IsleOfLostSkulls was previously pointing at Quarry's GUID;
|
||||
// real Location ReplicationId is 7e01cfe0-… (scene Quest_Pirate1_additive).
|
||||
("IsleOfLostSkulls", "The Isle of Lost Skulls", "Can your pirate crew get to the Isle, defeat its fearsome guardian, and escape with the gold?", "7e01cfe0-820a-406f-b1b3-0a5bf575235c", "recroomoriginal,quest", "image_IsleOfLostSkulls.png"),
|
||||
("Crescendo", "Crescendo of the Blood Moon", "Brave the haunted halls of Castle Dracula and survive the night.", "49cb8993-a956-43e2-86f4-1318f279b22a", "recroomoriginal,quest", "image_Crescendo.png"),
|
||||
("StuntRunner", "Stunt Runner", "A solo platforming gauntlet — sprint, climb and dodge to reach the trophy at the top.", "b7281665-a715-4051-826b-8e08e69c6172", "recroomoriginal,sport,quest", "image_StuntRunner.png"),
|
||||
("Drive-In", "Rec Drive-In", "Watch movies, hang out with friends, or chill at the bar.", "65ddbb48-5a01-4e3e-972d-e5c7419e2bc3", "recroomoriginal,featured", "image_DriveIn.png"),
|
||||
("IsleOfLostSkulls", "The Isle of Lost Skulls", "Can your pirate crew get to the Isle, defeat its fearsome guardian, and escape with the gold?", "7e01cfe0-820a-406f-b1b3-0a5bf575235c", "recroomoriginal,quest,co-op,adventure", "image_IsleOfLostSkulls.png"),
|
||||
("Crescendo", "Crescendo of the Blood Moon", "Brave the haunted halls of Castle Dracula and survive the night.", "49cb8993-a956-43e2-86f4-1318f279b22a", "recroomoriginal,quest,co-op,adventure", "image_Crescendo.png"),
|
||||
("StuntRunner", "Stunt Runner", "A solo platforming gauntlet — sprint, climb and dodge to reach the trophy at the top.", "b7281665-a715-4051-826b-8e08e69c6172", "recroomoriginal,sport,quest,stuntrunner,parkour", "image_StuntRunner.png"),
|
||||
("Drive-In", "Rec Drive-In", "Watch movies, hang out with friends, or chill at the bar.", "65ddbb48-5a01-4e3e-972d-e5c7419e2bc3", "recroomoriginal,featured,hangout,chill", "image_DriveIn.png"),
|
||||
// Hub / hang rooms — no specific gameplay, just shared spaces.
|
||||
("Park", "The Park", "An outdoor park with picnic tables and lawn games — a chill place to hang out.", "0a864c86-5a71-4e18-8041-8124e4dc9d98", "recroomoriginal,featured", "image_Park.png"),
|
||||
("PerformanceHall", "Performance Hall", "A big stage for live performances. Sing, perform, or just enjoy the show.", "9932f88f-3929-43a0-a012-a40b5128e346", "recroomoriginal", "image_PerformanceHall.png"),
|
||||
("EventRoom", "The Lounge", "A modular event hall with movable furniture for parties and meetings.", "a067557f-ca32-43e6-b6e5-daaec60b4f5a", "recroomoriginal", "image_EventRoom.png"),
|
||||
("BowlingAlley", "Bowling Alley", "Classic ten-pin bowling. Roll strikes, beat your friends.", "ae929543-9a07-41d5-8ee9-dbbee8c36800", "recroomoriginal,sport", "image_BowlingAlley.png"),
|
||||
("Park", "The Park", "An outdoor park with picnic tables and lawn games — a chill place to hang out.", "0a864c86-5a71-4e18-8041-8124e4dc9d98", "recroomoriginal,featured,hangout,chill", "image_Park.png"),
|
||||
("PerformanceHall", "Performance Hall", "A big stage for live performances. Sing, perform, or just enjoy the show.", "9932f88f-3929-43a0-a012-a40b5128e346", "recroomoriginal,hangout,music", "image_PerformanceHall.png"),
|
||||
("EventRoom", "The Lounge", "A modular event hall with movable furniture for parties and meetings.", "a067557f-ca32-43e6-b6e5-daaec60b4f5a", "recroomoriginal,hangout,chill", "image_EventRoom.png"),
|
||||
("BowlingAlley", "Bowling Alley", "Classic ten-pin bowling. Roll strikes, beat your friends.", "ae929543-9a07-41d5-8ee9-dbbee8c36800", "recroomoriginal,sport,bowling", "image_BowlingAlley.png"),
|
||||
// Sandbox templates (cloning canvases) — every "Home"-named
|
||||
// Location with a recognizable scene. Players land in these
|
||||
// when picking a stage for a custom room.
|
||||
("River", "River", "An outdoor template for building. Mountains, river, and forest as your starting canvas.", "e122fe98-e7db-49e8-a1b1-105424b6e1f0", "recroomoriginal,template", "image_River.png"),
|
||||
("Homestead", "Homestead", "A frontier-themed template — barn, fences, dusty trails. Build your homestead.", "a785267d-c579-42ea-be43-fec1992d1ca7", "recroomoriginal,template", "image_Homestead.png"),
|
||||
("Quarry", "Quarry", "A rocky open-pit template, ideal for industrial and obstacle-course builds.", "ff4c6427-7079-4f59-b22a-69b089420827", "recroomoriginal,template", "image_Quarry.png"),
|
||||
("Clearcut", "Clearcut", "An open clearing template — flat ground, mountains in the distance.", "380d18b5-de9c-49f3-80f7-f4a95c1de161", "recroomoriginal,template", "image_Clearcut.png"),
|
||||
("Spillway", "Spillway", "A water-park template — pools, waterslides, and concrete chutes for action builds.", "58763055-2dfb-4814-80b8-16fac5c85709", "recroomoriginal,template", "image_Spillway.png"),
|
||||
("MakerRoom", "Maker Room", "A blank-walled canvas. The classic starting point for a Maker Pen build.", "a75f7547-79eb-47c6-8986-6767abcb4f92", "recroomoriginal,template,featured", "image_RecCenter.png"),
|
||||
("River", "River", "An outdoor template for building. Mountains, river, and forest as your starting canvas.", "e122fe98-e7db-49e8-a1b1-105424b6e1f0", "recroomoriginal,template,creative,makerpen", "image_River.png"),
|
||||
("Homestead", "Homestead", "A frontier-themed template — barn, fences, dusty trails. Build your homestead.", "a785267d-c579-42ea-be43-fec1992d1ca7", "recroomoriginal,template,creative,makerpen", "image_Homestead.png"),
|
||||
("Quarry", "Quarry", "A rocky open-pit template, ideal for industrial and obstacle-course builds.", "ff4c6427-7079-4f59-b22a-69b089420827", "recroomoriginal,template,creative,makerpen", "image_Quarry.png"),
|
||||
("Clearcut", "Clearcut", "An open clearing template — flat ground, mountains in the distance.", "380d18b5-de9c-49f3-80f7-f4a95c1de161", "recroomoriginal,template,creative,makerpen", "image_Clearcut.png"),
|
||||
("Spillway", "Spillway", "A water-park template — pools, waterslides, and concrete chutes for action builds.", "58763055-2dfb-4814-80b8-16fac5c85709", "recroomoriginal,template,creative,makerpen", "image_Spillway.png"),
|
||||
("MakerRoom", "Maker Room", "A blank-walled canvas. The classic starting point for a Maker Pen build.", "a75f7547-79eb-47c6-8986-6767abcb4f92", "recroomoriginal,template,featured,creative,makerpen", "image_RecCenter.png"),
|
||||
};
|
||||
|
||||
// Stable id allocation: 100..1000 reserved for seeded rooms. User
|
||||
@@ -571,7 +571,10 @@ public class RoomService(DorkNetDbContext db)
|
||||
if (bareTag.Length > 0)
|
||||
{
|
||||
var needle = $"%{bareTag}%";
|
||||
q = q.Where(r => EF.Functions.Like(r.TagsCsv, needle));
|
||||
q = q.Where(r =>
|
||||
EF.Functions.Like(r.TagsCsv, needle) ||
|
||||
EF.Functions.Like(r.Name, needle) ||
|
||||
EF.Functions.Like(r.Description, needle));
|
||||
}
|
||||
}
|
||||
return await q.OrderByDescending(r => r.HotScore).Take(take).ToListAsync();
|
||||
|
||||
@@ -54,6 +54,24 @@ public class ServerSettingsService(DorkNetDbContext db)
|
||||
return ToWeeklySettings(row);
|
||||
}
|
||||
|
||||
public async Task<PlayMenuTagSettings> GetPlayMenuTagsAsync()
|
||||
{
|
||||
var row = await GetAsync();
|
||||
return ToPlayMenuTags(row);
|
||||
}
|
||||
|
||||
public async Task<PlayMenuTagSettings> SetPlayMenuTagsAsync(
|
||||
IReadOnlyList<string> pinned,
|
||||
IReadOnlyList<string> popular)
|
||||
{
|
||||
var normalized = NormalizePlayMenuTags(pinned, popular);
|
||||
var existing = await GetTrackedRowAsync();
|
||||
existing.PlayMenuTagsJson = JsonSerializer.Serialize(normalized, JsonOptions);
|
||||
existing.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return normalized with { UpdatedAt = existing.UpdatedAt };
|
||||
}
|
||||
|
||||
public async Task<WeeklyChallengeSettings> SetWeeklyChallengesAsync(
|
||||
bool completedRequired,
|
||||
IReadOnlyList<WeeklyChallengeTemplate> challenges,
|
||||
@@ -122,6 +140,29 @@ public class ServerSettingsService(DorkNetDbContext db)
|
||||
row.UpdatedAt);
|
||||
}
|
||||
|
||||
private static PlayMenuTagSettings ToPlayMenuTags(ServerSettingsEntity row)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(row.PlayMenuTagsJson))
|
||||
{
|
||||
try
|
||||
{
|
||||
var parsed = JsonSerializer.Deserialize<PlayMenuTagSettings>(
|
||||
row.PlayMenuTagsJson,
|
||||
JsonOptions);
|
||||
if (parsed is not null)
|
||||
return NormalizePlayMenuTags(parsed.PinnedTags, parsed.PopularTags)
|
||||
with { UpdatedAt = row.UpdatedAt };
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Bad settings should not break the Play menu; admins can
|
||||
// overwrite them from the SPA.
|
||||
}
|
||||
}
|
||||
|
||||
return DefaultPlayMenuTags() with { UpdatedAt = row.UpdatedAt };
|
||||
}
|
||||
|
||||
private static List<WeeklyChallengeTemplate> NormalizeWeeklyChallenges(
|
||||
IReadOnlyList<WeeklyChallengeTemplate> challenges)
|
||||
{
|
||||
@@ -189,6 +230,65 @@ public class ServerSettingsService(DorkNetDbContext db)
|
||||
ConsumableItemDesc: string.Empty,
|
||||
EquipmentPrefabName: string.Empty,
|
||||
EquipmentModificationGuid: string.Empty);
|
||||
|
||||
public static PlayMenuTagSettings DefaultPlayMenuTags() =>
|
||||
new(
|
||||
PinnedTags:
|
||||
[
|
||||
"community",
|
||||
"recroomoriginal",
|
||||
"featured",
|
||||
"quest",
|
||||
"sport",
|
||||
"template",
|
||||
"hangout",
|
||||
"creative",
|
||||
],
|
||||
PopularTags:
|
||||
[
|
||||
"paintball",
|
||||
"dodgeball",
|
||||
"soccer",
|
||||
"lasertag",
|
||||
"recroyale",
|
||||
"discgolf",
|
||||
"charades",
|
||||
"bowling",
|
||||
"paddleball",
|
||||
"stuntrunner",
|
||||
"makerpen",
|
||||
"pvp",
|
||||
"co-op",
|
||||
"chill",
|
||||
"music",
|
||||
"parkour",
|
||||
],
|
||||
UpdatedAt: DateTime.UtcNow);
|
||||
|
||||
private static PlayMenuTagSettings NormalizePlayMenuTags(
|
||||
IReadOnlyList<string> pinned,
|
||||
IReadOnlyList<string> popular)
|
||||
{
|
||||
static List<string> Normalize(IReadOnlyList<string> tags, int max)
|
||||
{
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
return tags
|
||||
.Select(t => (t ?? string.Empty).Trim().TrimStart('#').ToLowerInvariant())
|
||||
.Where(t => t.Length > 0)
|
||||
.Where(t => t.All(c => char.IsLetterOrDigit(c) || c is '-' or '_'))
|
||||
.Where(seen.Add)
|
||||
.Take(max)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
var defaults = DefaultPlayMenuTags();
|
||||
var pinnedTags = Normalize(pinned, 16);
|
||||
var popularTags = Normalize(popular, 32);
|
||||
return new PlayMenuTagSettings(
|
||||
pinnedTags.Count > 0 ? pinnedTags : defaults.PinnedTags,
|
||||
popularTags.Count > 0 ? popularTags : defaults.PopularTags,
|
||||
DateTime.UtcNow);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record WeeklyChallengeSettings(
|
||||
@@ -215,3 +315,8 @@ public sealed record WeeklyChallengeReward(
|
||||
string ConsumableItemDesc,
|
||||
string EquipmentPrefabName,
|
||||
string EquipmentModificationGuid);
|
||||
|
||||
public sealed record PlayMenuTagSettings(
|
||||
List<string> PinnedTags,
|
||||
List<string> PopularTags,
|
||||
DateTime UpdatedAt);
|
||||
|
||||
@@ -100,6 +100,7 @@ public static class DatabaseBootstrap
|
||||
// idempotent raw SQL that works on a fresh DB too (CREATE TABLE
|
||||
// IF NOT EXISTS no-ops when EnsureCreated already built them).
|
||||
await EnsureSignupCodeTablesAsync(db);
|
||||
await EnsureServerSettingsColumnsAsync(db);
|
||||
|
||||
// Coach system account at Player.Id=1. The RR-Original room seeder
|
||||
// below assigns CreatorPlayerId=1 to every canonical room
|
||||
@@ -236,6 +237,29 @@ public static class DatabaseBootstrap
|
||||
await db.Database.ExecuteSqlRawAsync(sql);
|
||||
}
|
||||
|
||||
private static async Task EnsureServerSettingsColumnsAsync(DorkNetDbContext db)
|
||||
{
|
||||
var provider = db.Database.ProviderName ?? string.Empty;
|
||||
if (provider.Contains("Npgsql", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
await db.Database.ExecuteSqlRawAsync(
|
||||
@"ALTER TABLE ""ServerSettings"" ADD COLUMN IF NOT EXISTS ""PlayMenuTagsJson"" text NOT NULL DEFAULT '';");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await db.Database.ExecuteSqlRawAsync(
|
||||
@"ALTER TABLE ""ServerSettings"" ADD COLUMN ""PlayMenuTagsJson"" TEXT NOT NULL DEFAULT '';");
|
||||
}
|
||||
catch
|
||||
{
|
||||
// SQLite has no ADD COLUMN IF NOT EXISTS. If it already exists,
|
||||
// ignore the duplicate-column error; any other schema problem
|
||||
// will surface when EF reads ServerSettings.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>SQLite migration-history reconciliation. Handles two
|
||||
/// cutover scenarios so legacy DBs boot cleanly without manual SQL:
|
||||
/// <list type="number">
|
||||
|
||||
@@ -10,8 +10,20 @@ import { useToast } from '../components/Toast';
|
||||
import { Confirm } from '../components/Confirm';
|
||||
import { RefreshCw, Trash, Upload } from '../components/Icons';
|
||||
|
||||
interface PlayMenuTagSettings {
|
||||
pinnedTags: string[];
|
||||
popularTags: string[];
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export function Rooms() {
|
||||
const { data, loading, error, refresh } = useApi<Room[]>('/rooms');
|
||||
const {
|
||||
data: playMenuTags,
|
||||
loading: tagsLoading,
|
||||
error: tagsError,
|
||||
refresh: refreshPlayMenuTags,
|
||||
} = useApi<PlayMenuTagSettings>('/settings/play-menu-tags');
|
||||
const [filter, setFilter] = useState('');
|
||||
const [filterKind, setFilterKind] = useState<'all' | 'original' | 'custom' | 'dorm'>('all');
|
||||
const [pendingDelete, setPendingDelete] = useState<Room | null>(null);
|
||||
@@ -64,6 +76,13 @@ export function Rooms() {
|
||||
</>}
|
||||
/>
|
||||
|
||||
<PlayMenuTagsPanel
|
||||
tags={playMenuTags}
|
||||
loading={tagsLoading}
|
||||
error={tagsError}
|
||||
onSaved={refreshPlayMenuTags}
|
||||
/>
|
||||
|
||||
<div className="card overflow-hidden">
|
||||
<div className="flex flex-wrap items-center gap-2 border-b border-ink-800 px-3 py-2.5">
|
||||
<input value={filter} onChange={e => setFilter(e.target.value)} placeholder="Filter by name…" className="input max-w-xs" />
|
||||
@@ -156,6 +175,102 @@ export function Rooms() {
|
||||
);
|
||||
}
|
||||
|
||||
function PlayMenuTagsPanel({
|
||||
tags,
|
||||
loading,
|
||||
error,
|
||||
onSaved,
|
||||
}: {
|
||||
tags: PlayMenuTagSettings | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const [pinnedText, setPinnedText] = useState('');
|
||||
const [popularText, setPopularText] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const toast = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
if (!tags) return;
|
||||
setPinnedText(tags.pinnedTags.join('\n'));
|
||||
setPopularText(tags.popularTags.join('\n'));
|
||||
}, [tags]);
|
||||
|
||||
const parseTags = (text: string) =>
|
||||
text
|
||||
.split(/[\n,]+/)
|
||||
.map(t => t.trim().replace(/^#+/, '').toLowerCase())
|
||||
.filter(Boolean);
|
||||
|
||||
const save = async (reset = false) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await api<PlayMenuTagSettings>('/settings/play-menu-tags', {
|
||||
method: 'POST',
|
||||
body: reset
|
||||
? { PinnedTags: [], PopularTags: [] }
|
||||
: { PinnedTags: parseTags(pinnedText), PopularTags: parseTags(popularText) },
|
||||
});
|
||||
toast.push(reset ? 'Play menu tags reset.' : 'Play menu tags saved.', 'success');
|
||||
onSaved();
|
||||
} catch (e) {
|
||||
toast.push((e as Error).message, 'error');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card !p-4 mb-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-ink-50">Play menu tags</h2>
|
||||
<p className="mt-1 text-xs text-ink-400">
|
||||
Filter chips shown in the watch Play menu. Use one tag per line or commas; leave off the #.
|
||||
</p>
|
||||
{tags && (
|
||||
<p className="mt-1 text-[11px] text-ink-500">
|
||||
Last changed {new Date(tags.updatedAt).toLocaleString()}.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => save(true)} disabled={busy || loading} className="btn-secondary text-xs">
|
||||
Defaults
|
||||
</button>
|
||||
<button onClick={() => save(false)} disabled={busy || loading} className="btn-primary text-xs">
|
||||
{busy ? 'Saving...' : 'Save tags'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="mt-3 text-sm text-danger">{error}</div>}
|
||||
{loading && !tags && <div className="mt-3 text-xs text-ink-400">Loading tags...</div>}
|
||||
{tags && (
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-2">
|
||||
<label className="block">
|
||||
<span className="label">Pinned tags</span>
|
||||
<textarea
|
||||
value={pinnedText}
|
||||
onChange={e => setPinnedText(e.target.value)}
|
||||
className="input mt-1 min-h-36 font-mono text-xs"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="label">Popular tags</span>
|
||||
<textarea
|
||||
value={popularText}
|
||||
onChange={e => setPopularText(e.target.value)}
|
||||
className="input mt-1 min-h-36 font-mono text-xs"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Per-room hard-delete modal with two typed-name confirmations ─────────
|
||||
// The previous bulk "purge all custom" button was removed — too easy to
|
||||
// click by accident. Each room now gets its own purge flow that requires
|
||||
|
||||
Reference in New Issue
Block a user