mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 23:18:44 -07:00
Add Rec Center doors management: implement endpoints for retrieving and updating door configurations; enhance database schema and UI integration
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.GameConfigurations;
|
||||
|
||||
[ApiController]
|
||||
public class GameConfigurationsController : ControllerBase
|
||||
public class GameConfigurationsController(ServerSettingsService serverSettings) : ControllerBase
|
||||
{
|
||||
// Actual path used by the 2020 build
|
||||
[HttpGet("api/gameconfigs/v1/all")]
|
||||
@@ -11,5 +12,6 @@ public class GameConfigurationsController : ControllerBase
|
||||
// Legacy path we originally assumed
|
||||
[HttpGet("api/gameconfigurations/v1/all")]
|
||||
[HttpGet("api/gameconfigurations/v1")]
|
||||
public ActionResult<List<object>> GetAll() => Ok(new List<object>());
|
||||
public async Task<ActionResult<IReadOnlyList<GameConfigurationSetting>>> GetAll()
|
||||
=> Ok(await serverSettings.GetGameConfigurationsAsync());
|
||||
}
|
||||
|
||||
@@ -1514,6 +1514,23 @@ public class AdminController(
|
||||
return Ok(tags);
|
||||
}
|
||||
|
||||
[HttpGet("settings/rec-center-doors")]
|
||||
public async Task<ActionResult> GetRecCenterDoors()
|
||||
=> Ok(await serverSettings.GetRecCenterDoorsAsync());
|
||||
|
||||
public sealed record RecCenterDoorsRequest(
|
||||
List<RecCenterDoorConfig>? Doors);
|
||||
|
||||
[HttpPost("settings/rec-center-doors")]
|
||||
public async Task<ActionResult> SetRecCenterDoors([FromBody] RecCenterDoorsRequest body)
|
||||
{
|
||||
var doors = await serverSettings.SetRecCenterDoorsAsync(body.Doors ?? []);
|
||||
await LogAsync("rec_center_doors_updated", "system", 0,
|
||||
string.Join(";", doors.Doors.Select(d => $"{d.Key}:{d.Query}")));
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(doors);
|
||||
}
|
||||
|
||||
// ── Signup codes ─────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>GET <c>api/admin/v1/signup-codes</c> — every issued code
|
||||
|
||||
@@ -610,6 +610,7 @@ public class DorkNetDbContext(DbContextOptions<DorkNetDbContext> options) : DbCo
|
||||
e.Property(s => s.WeeklyChallengesJson).HasDefaultValue(string.Empty);
|
||||
e.Property(s => s.WeeklyChallengeRewardJson).HasDefaultValue(string.Empty);
|
||||
e.Property(s => s.PlayMenuTagsJson).HasDefaultValue(string.Empty);
|
||||
e.Property(s => s.RecCenterDoorsJson).HasDefaultValue(string.Empty);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<RoomKeyEntity>(e =>
|
||||
|
||||
@@ -43,5 +43,10 @@ public class ServerSettingsEntity
|
||||
/// the watch without leading # characters.</summary>
|
||||
public string PlayMenuTagsJson { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Admin-managed Rec Center category-door config. The
|
||||
/// 2020.12 client reads this through api/gameconfigs/v1/all as
|
||||
/// Door.{Category}.Title and Door.{Category}.Query entries.</summary>
|
||||
public string RecCenterDoorsJson { get; set; } = string.Empty;
|
||||
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
@@ -45,15 +45,15 @@ public class ConfigService(IConfiguration config, DomainConfig domain)
|
||||
// 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.Shooters.Query"] = "#paintball|#lasertag|#recroyale",
|
||||
["Door.Creative.Title"] = "Creative",
|
||||
["Door.Creative.Query"] = "#creative,#makerpen,#template",
|
||||
["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",
|
||||
["Door.Featured.Query"] = "#featured|#recroomoriginal",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -60,6 +60,18 @@ public class ServerSettingsService(DorkNetDbContext db)
|
||||
return ToPlayMenuTags(row);
|
||||
}
|
||||
|
||||
public async Task<RecCenterDoorSettings> GetRecCenterDoorsAsync()
|
||||
{
|
||||
var row = await GetAsync();
|
||||
return ToRecCenterDoors(row);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<GameConfigurationSetting>> GetGameConfigurationsAsync()
|
||||
{
|
||||
var doors = await GetRecCenterDoorsAsync();
|
||||
return ToGameConfigurations(doors);
|
||||
}
|
||||
|
||||
public async Task<PlayMenuTagSettings> SetPlayMenuTagsAsync(
|
||||
IReadOnlyList<string> pinned,
|
||||
IReadOnlyList<string> popular)
|
||||
@@ -72,6 +84,17 @@ public class ServerSettingsService(DorkNetDbContext db)
|
||||
return normalized with { UpdatedAt = existing.UpdatedAt };
|
||||
}
|
||||
|
||||
public async Task<RecCenterDoorSettings> SetRecCenterDoorsAsync(
|
||||
IReadOnlyList<RecCenterDoorConfig> doors)
|
||||
{
|
||||
var normalized = NormalizeRecCenterDoors(doors);
|
||||
var existing = await GetTrackedRowAsync();
|
||||
existing.RecCenterDoorsJson = 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,
|
||||
@@ -163,6 +186,29 @@ public class ServerSettingsService(DorkNetDbContext db)
|
||||
return DefaultPlayMenuTags() with { UpdatedAt = row.UpdatedAt };
|
||||
}
|
||||
|
||||
private static RecCenterDoorSettings ToRecCenterDoors(ServerSettingsEntity row)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(row.RecCenterDoorsJson))
|
||||
{
|
||||
try
|
||||
{
|
||||
var parsed = JsonSerializer.Deserialize<RecCenterDoorSettings>(
|
||||
row.RecCenterDoorsJson,
|
||||
JsonOptions);
|
||||
if (parsed is not null)
|
||||
return NormalizeRecCenterDoors(parsed.Doors)
|
||||
with { UpdatedAt = row.UpdatedAt };
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Bad settings should not break Rec Center startup; admins
|
||||
// can overwrite them from the SPA.
|
||||
}
|
||||
}
|
||||
|
||||
return DefaultRecCenterDoors() with { UpdatedAt = row.UpdatedAt };
|
||||
}
|
||||
|
||||
private static List<WeeklyChallengeTemplate> NormalizeWeeklyChallenges(
|
||||
IReadOnlyList<WeeklyChallengeTemplate> challenges)
|
||||
{
|
||||
@@ -265,6 +311,42 @@ public class ServerSettingsService(DorkNetDbContext db)
|
||||
],
|
||||
UpdatedAt: DateTime.UtcNow);
|
||||
|
||||
public static RecCenterDoorSettings DefaultRecCenterDoors() =>
|
||||
new(
|
||||
Doors:
|
||||
[
|
||||
new("Shooters", "Shooters", "#paintball|#lasertag|#recroyale"),
|
||||
new("Creative", "Creative", "#creative|#makerpen|#template"),
|
||||
new("Quests", "Quests", "#quest"),
|
||||
new("Sports", "Sports", "#sport"),
|
||||
new("Featured", "Featured", "#featured|#recroomoriginal"),
|
||||
],
|
||||
UpdatedAt: DateTime.UtcNow);
|
||||
|
||||
public static IReadOnlyList<GameConfigurationSetting> ToGameConfigurations(
|
||||
RecCenterDoorSettings doors)
|
||||
{
|
||||
var rows = new List<GameConfigurationSetting>();
|
||||
foreach (var door in NormalizeRecCenterDoors(doors.Doors).Doors)
|
||||
{
|
||||
rows.Add(new($"Door.{door.Key}.Title", door.Title));
|
||||
rows.Add(new($"Door.{door.Key}.Query", door.Query));
|
||||
}
|
||||
|
||||
rows.AddRange([
|
||||
new("Rewards.UseRewardSelection", "false"),
|
||||
new("RoomDetails.PhotoRollEnabled", "false"),
|
||||
new("SynchronizedField.RemoveDefaultEntries", "false"),
|
||||
new("Rendering.DisableSrpBatcher", "false"),
|
||||
new("splitTestSoftOverrides", "{}"),
|
||||
new("splitTestHardOverrides", "{}"),
|
||||
new("splitTestSegmentProbabilities", "{}"),
|
||||
new("loadingNetworkTimeout", "30"),
|
||||
new("runningNetworkTimeout", "30"),
|
||||
]);
|
||||
return rows;
|
||||
}
|
||||
|
||||
private static PlayMenuTagSettings NormalizePlayMenuTags(
|
||||
IReadOnlyList<string> pinned,
|
||||
IReadOnlyList<string> popular)
|
||||
@@ -289,6 +371,50 @@ public class ServerSettingsService(DorkNetDbContext db)
|
||||
popularTags.Count > 0 ? popularTags : defaults.PopularTags,
|
||||
DateTime.UtcNow);
|
||||
}
|
||||
|
||||
private static RecCenterDoorSettings NormalizeRecCenterDoors(
|
||||
IReadOnlyList<RecCenterDoorConfig> doors)
|
||||
{
|
||||
static string NormalizeKey(string value)
|
||||
{
|
||||
var clean = new string((value ?? string.Empty)
|
||||
.Where(char.IsLetterOrDigit)
|
||||
.ToArray());
|
||||
return string.IsNullOrWhiteSpace(clean) ? string.Empty : clean;
|
||||
}
|
||||
|
||||
static string NormalizeQuery(string value)
|
||||
{
|
||||
var tags = (value ?? string.Empty)
|
||||
.Split(['|', ',', '\n', '\r', ' ', '\t'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(t => t.Trim().TrimStart('#').ToLowerInvariant())
|
||||
.Where(t => t.Length > 0)
|
||||
.Where(t => t.All(c => char.IsLetterOrDigit(c) || c is '-' or '_'))
|
||||
.Select(t => $"#{t}")
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.Take(12)
|
||||
.ToList();
|
||||
return string.Join("|", tags);
|
||||
}
|
||||
|
||||
var normalized = doors
|
||||
.Select(d =>
|
||||
{
|
||||
var key = NormalizeKey(d.Key);
|
||||
var title = string.IsNullOrWhiteSpace(d.Title) ? key : d.Title.Trim();
|
||||
var query = NormalizeQuery(d.Query);
|
||||
return new RecCenterDoorConfig(key, title, query);
|
||||
})
|
||||
.Where(d => d.Key.Length > 0 && d.Query.Length > 0)
|
||||
.GroupBy(d => d.Key, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(g => g.First())
|
||||
.Take(8)
|
||||
.ToList();
|
||||
|
||||
return new RecCenterDoorSettings(
|
||||
normalized.Count > 0 ? normalized : DefaultRecCenterDoors().Doors,
|
||||
DateTime.UtcNow);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record WeeklyChallengeSettings(
|
||||
@@ -320,3 +446,18 @@ public sealed record PlayMenuTagSettings(
|
||||
List<string> PinnedTags,
|
||||
List<string> PopularTags,
|
||||
DateTime UpdatedAt);
|
||||
|
||||
public sealed record RecCenterDoorSettings(
|
||||
List<RecCenterDoorConfig> Doors,
|
||||
DateTime UpdatedAt);
|
||||
|
||||
public sealed record RecCenterDoorConfig(
|
||||
string Key,
|
||||
string Title,
|
||||
string Query);
|
||||
|
||||
public sealed record GameConfigurationSetting(
|
||||
string Key,
|
||||
string Value,
|
||||
DateTime? StartTime = null,
|
||||
DateTime? EndTime = null);
|
||||
|
||||
@@ -244,6 +244,8 @@ public static class DatabaseBootstrap
|
||||
{
|
||||
await db.Database.ExecuteSqlRawAsync(
|
||||
@"ALTER TABLE ""ServerSettings"" ADD COLUMN IF NOT EXISTS ""PlayMenuTagsJson"" text NOT NULL DEFAULT '';");
|
||||
await db.Database.ExecuteSqlRawAsync(
|
||||
@"ALTER TABLE ""ServerSettings"" ADD COLUMN IF NOT EXISTS ""RecCenterDoorsJson"" text NOT NULL DEFAULT '';");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -258,6 +260,18 @@ public static class DatabaseBootstrap
|
||||
// ignore the duplicate-column error; any other schema problem
|
||||
// will surface when EF reads ServerSettings.
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await db.Database.ExecuteSqlRawAsync(
|
||||
@"ALTER TABLE ""ServerSettings"" ADD COLUMN ""RecCenterDoorsJson"" 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
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Empty } from '../components/Empty';
|
||||
import { Modal } from '../components/Modal';
|
||||
import { useToast } from '../components/Toast';
|
||||
import { Confirm } from '../components/Confirm';
|
||||
import { RefreshCw, Trash, Upload } from '../components/Icons';
|
||||
import { Plus, RefreshCw, Trash, Upload } from '../components/Icons';
|
||||
|
||||
interface PlayMenuTagSettings {
|
||||
pinnedTags: string[];
|
||||
@@ -16,6 +16,17 @@ interface PlayMenuTagSettings {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface RecCenterDoorConfig {
|
||||
key: string;
|
||||
title: string;
|
||||
query: string;
|
||||
}
|
||||
|
||||
interface RecCenterDoorSettings {
|
||||
doors: RecCenterDoorConfig[];
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export function Rooms() {
|
||||
const { data, loading, error, refresh } = useApi<Room[]>('/rooms');
|
||||
const {
|
||||
@@ -24,6 +35,12 @@ export function Rooms() {
|
||||
error: tagsError,
|
||||
refresh: refreshPlayMenuTags,
|
||||
} = useApi<PlayMenuTagSettings>('/settings/play-menu-tags');
|
||||
const {
|
||||
data: recCenterDoors,
|
||||
loading: doorsLoading,
|
||||
error: doorsError,
|
||||
refresh: refreshRecCenterDoors,
|
||||
} = useApi<RecCenterDoorSettings>('/settings/rec-center-doors');
|
||||
const [filter, setFilter] = useState('');
|
||||
const [filterKind, setFilterKind] = useState<'all' | 'original' | 'custom' | 'dorm'>('all');
|
||||
const [pendingDelete, setPendingDelete] = useState<Room | null>(null);
|
||||
@@ -83,6 +100,13 @@ export function Rooms() {
|
||||
onSaved={refreshPlayMenuTags}
|
||||
/>
|
||||
|
||||
<RecCenterDoorsPanel
|
||||
settings={recCenterDoors}
|
||||
loading={doorsLoading}
|
||||
error={doorsError}
|
||||
onSaved={refreshRecCenterDoors}
|
||||
/>
|
||||
|
||||
<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" />
|
||||
@@ -186,23 +210,17 @@ function PlayMenuTagsPanel({
|
||||
error: string | null;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const [pinnedText, setPinnedText] = useState('');
|
||||
const [popularText, setPopularText] = useState('');
|
||||
const [pinnedTags, setPinnedTags] = useState<string[]>([]);
|
||||
const [popularTags, setPopularTags] = useState<string[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const toast = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
if (!tags) return;
|
||||
setPinnedText(tags.pinnedTags.join('\n'));
|
||||
setPopularText(tags.popularTags.join('\n'));
|
||||
setPinnedTags(tags.pinnedTags);
|
||||
setPopularTags(tags.popularTags);
|
||||
}, [tags]);
|
||||
|
||||
const parseTags = (text: string) =>
|
||||
text
|
||||
.split(/[\n,]+/)
|
||||
.map(t => t.trim().replace(/^#+/, '').toLowerCase())
|
||||
.filter(Boolean);
|
||||
|
||||
const save = async (reset = false) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
@@ -210,7 +228,7 @@ function PlayMenuTagsPanel({
|
||||
method: 'POST',
|
||||
body: reset
|
||||
? { PinnedTags: [], PopularTags: [] }
|
||||
: { PinnedTags: parseTags(pinnedText), PopularTags: parseTags(popularText) },
|
||||
: { PinnedTags: pinnedTags, PopularTags: popularTags },
|
||||
});
|
||||
toast.push(reset ? 'Play menu tags reset.' : 'Play menu tags saved.', 'success');
|
||||
onSaved();
|
||||
@@ -227,7 +245,7 @@ function PlayMenuTagsPanel({
|
||||
<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 #.
|
||||
Filter chips shown in the watch Play menu.
|
||||
</p>
|
||||
{tags && (
|
||||
<p className="mt-1 text-[11px] text-ink-500">
|
||||
@@ -249,28 +267,224 @@ function PlayMenuTagsPanel({
|
||||
{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>
|
||||
<TagListEditor
|
||||
label="Pinned tags"
|
||||
tags={pinnedTags}
|
||||
onChange={setPinnedTags}
|
||||
max={16}
|
||||
/>
|
||||
<TagListEditor
|
||||
label="Popular tags"
|
||||
tags={popularTags}
|
||||
onChange={setPopularTags}
|
||||
max={32}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RecCenterDoorsPanel({
|
||||
settings,
|
||||
loading,
|
||||
error,
|
||||
onSaved,
|
||||
}: {
|
||||
settings: RecCenterDoorSettings | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const [doors, setDoors] = useState<RecCenterDoorConfig[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const toast = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
if (!settings) return;
|
||||
setDoors(settings.doors);
|
||||
}, [settings]);
|
||||
|
||||
const updateDoor = (index: number, patch: Partial<RecCenterDoorConfig>) => {
|
||||
setDoors(current => current.map((door, i) => i === index ? { ...door, ...patch } : door));
|
||||
};
|
||||
|
||||
const removeDoor = (index: number) => {
|
||||
setDoors(current => current.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const addDoor = () => {
|
||||
setDoors(current => [...current, { key: 'New', title: 'New', query: '#tag' }]);
|
||||
};
|
||||
|
||||
const save = async (reset = false) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await api<RecCenterDoorSettings>('/settings/rec-center-doors', {
|
||||
method: 'POST',
|
||||
body: reset ? { Doors: [] } : { Doors: doors },
|
||||
});
|
||||
toast.push(reset ? 'Rec Center doors reset.' : 'Rec Center doors 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">Rec Center doors</h2>
|
||||
<p className="mt-1 text-xs text-ink-400">
|
||||
Door titles and tag queries served through the 2020 gameconfig endpoint.
|
||||
</p>
|
||||
{settings && (
|
||||
<p className="mt-1 text-[11px] text-ink-500">
|
||||
Last changed {new Date(settings.updatedAt).toLocaleString()}.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={addDoor} disabled={busy || loading} className="btn-secondary text-xs">
|
||||
<Plus /> Add
|
||||
</button>
|
||||
<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 doors'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="mt-3 text-sm text-danger">{error}</div>}
|
||||
{loading && !settings && <div className="mt-3 text-xs text-ink-400">Loading Rec Center doors...</div>}
|
||||
{settings && (
|
||||
<div className="mt-4 table-scroll">
|
||||
<table className="w-full min-w-[760px] text-sm">
|
||||
<thead className="text-[11px] uppercase tracking-wider text-ink-400 bg-ink-900/50 border-y border-ink-800">
|
||||
<tr>
|
||||
<th className="text-left font-medium px-3 py-2">Key</th>
|
||||
<th className="text-left font-medium px-3 py-2">Title</th>
|
||||
<th className="text-left font-medium px-3 py-2">Query tags</th>
|
||||
<th className="w-12" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-ink-800">
|
||||
{doors.map((door, index) => (
|
||||
<tr key={`${door.key}-${index}`}>
|
||||
<td className="px-3 py-2 align-top">
|
||||
<input
|
||||
value={door.key}
|
||||
onChange={e => updateDoor(index, { key: e.target.value })}
|
||||
className="input font-mono text-xs"
|
||||
placeholder="Shooters"
|
||||
/>
|
||||
<div className="mt-1 text-[11px] text-ink-500">Door.{door.key || 'Key'}.*</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 align-top">
|
||||
<input
|
||||
value={door.title}
|
||||
onChange={e => updateDoor(index, { title: e.target.value })}
|
||||
className="input"
|
||||
placeholder="Shooters"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2 align-top">
|
||||
<input
|
||||
value={door.query}
|
||||
onChange={e => updateDoor(index, { query: e.target.value })}
|
||||
className="input font-mono text-xs"
|
||||
placeholder="#paintball|#lasertag"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2 align-top text-right">
|
||||
<button
|
||||
onClick={() => removeDoor(index)}
|
||||
className="btn-ghost text-xs text-danger"
|
||||
title="Remove door"
|
||||
>
|
||||
<Trash />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TagListEditor({
|
||||
label,
|
||||
tags,
|
||||
onChange,
|
||||
max,
|
||||
}: {
|
||||
label: string;
|
||||
tags: string[];
|
||||
onChange: (tags: string[]) => void;
|
||||
max: number;
|
||||
}) {
|
||||
const [draft, setDraft] = useState('');
|
||||
|
||||
const normalize = (value: string) =>
|
||||
value.trim().replace(/^#+/, '').toLowerCase();
|
||||
|
||||
const add = () => {
|
||||
const next = normalize(draft);
|
||||
if (!next || tags.includes(next) || tags.length >= max) return;
|
||||
onChange([...tags, next]);
|
||||
setDraft('');
|
||||
};
|
||||
|
||||
const remove = (tag: string) => {
|
||||
onChange(tags.filter(t => t !== tag));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-ink-800 bg-ink-950/30 p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="label">{label}</span>
|
||||
<span className="text-[11px] text-ink-500">{tags.length}/{max}</span>
|
||||
</div>
|
||||
<div className="mt-2 flex gap-2">
|
||||
<input
|
||||
value={draft}
|
||||
onChange={e => setDraft(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
add();
|
||||
}
|
||||
}}
|
||||
className="input font-mono text-xs"
|
||||
placeholder="paintball"
|
||||
/>
|
||||
<button onClick={add} disabled={!draft.trim() || tags.length >= max} className="btn-secondary text-xs">
|
||||
<Plus /> Add
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-3 flex min-h-20 flex-wrap content-start gap-2">
|
||||
{tags.map(tag => (
|
||||
<span key={tag} className="inline-flex items-center gap-1 rounded-md border border-ink-700 bg-ink-900 px-2 py-1 text-xs text-ink-100">
|
||||
<span className="font-mono">#{tag}</span>
|
||||
<button onClick={() => remove(tag)} className="text-ink-500 hover:text-danger" title={`Remove ${tag}`}>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
{tags.length === 0 && <span className="text-xs text-ink-500">No tags set. Defaults will be used on save.</span>}
|
||||
</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