mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-27 16:08:47 -07:00
feat: Implement base room management; add API endpoints for retrieving and setting curated base rooms
This commit is contained in:
@@ -293,14 +293,30 @@ public class RoomsController(
|
|||||||
=> Ok((await rooms.HotAsync(null)).Select(RoomService.ToWireRoom).ToList());
|
=> Ok((await rooms.HotAsync(null)).Select(RoomService.ToWireRoom).ToList());
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// `Rooms.GetBaseRooms` — list of "base" rooms used by Rec Room Originals
|
/// `Rooms.GetBaseRooms` — the room-creation "base room" picker. A fixed,
|
||||||
/// to seed the room creation UI. Returns the same set as #recroomoriginal.
|
/// admin-curated list of room NAMES (server setting
|
||||||
|
/// <c>BaseRoomNamesJson</c>, default <see
|
||||||
|
/// cref="ServerSettingsService.DefaultBaseRoomNames"/>), resolved to rooms
|
||||||
|
/// in the configured order. Names are looked up via
|
||||||
|
/// <see cref="RoomService.GetByNameAsync"/>, which also finds
|
||||||
|
/// HiddenFromBrowse rooms like MakerRoom. Edit the list from the admin SPA
|
||||||
|
/// (<c>api/admin/v1/settings/base-rooms</c>).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpGet("api/rooms/v2/baserooms")]
|
[HttpGet("api/rooms/v2/baserooms")]
|
||||||
[HttpGet("api/rooms/v3/baserooms")]
|
[HttpGet("api/rooms/v3/baserooms")]
|
||||||
[HttpGet("rooms/base")]
|
[HttpGet("rooms/base")]
|
||||||
public async Task<IActionResult> BaseRooms()
|
public async Task<IActionResult> BaseRooms()
|
||||||
=> Ok((await rooms.HotAsync("#recroomoriginal")).Select(RoomService.ToWireRoom).ToList());
|
{
|
||||||
|
var names = await serverSettings.GetBaseRoomNamesAsync();
|
||||||
|
var result = new List<object>();
|
||||||
|
foreach (var name in names)
|
||||||
|
{
|
||||||
|
var room = await rooms.GetByNameAsync(name);
|
||||||
|
if (room is not null)
|
||||||
|
result.Add(RoomService.ToWireRoom(room));
|
||||||
|
}
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// `Rooms.GetFilters` — pinned/popular tag chips shown above the room
|
/// `Rooms.GetFilters` — pinned/popular tag chips shown above the room
|
||||||
|
|||||||
@@ -984,8 +984,20 @@ public class RoomsModerationController(DorkNetDbContext db) : ControllerBase
|
|||||||
newName = $"{baseName}{suffix}";
|
newName = $"{baseName}{suffix}";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Assign an explicit user-room id (> 1000), the same way
|
||||||
|
// RoomService.CloneAsync does. Relying on DB auto-increment is unsafe:
|
||||||
|
// seeded rooms were inserted with manual ids (100..1000) without
|
||||||
|
// advancing the Postgres identity sequence, so auto-increment collides
|
||||||
|
// with a seeded id → PK violation → the clone POST 500s → the client
|
||||||
|
// reports "Failed to clone room" and boots the player to their dorm.
|
||||||
|
// (SQLite in tests tracks max(id), which is why the probe passed.)
|
||||||
|
var nextId = await db.Rooms
|
||||||
|
.Where(r => r.Id > 1000)
|
||||||
|
.MaxAsync(r => (long?)r.Id) ?? 1000L;
|
||||||
|
|
||||||
var clone = new RoomEntity
|
var clone = new RoomEntity
|
||||||
{
|
{
|
||||||
|
Id = nextId + 1,
|
||||||
Name = newName,
|
Name = newName,
|
||||||
Description = source.Description,
|
Description = source.Description,
|
||||||
CreatorPlayerId = Me,
|
CreatorPlayerId = Me,
|
||||||
@@ -1015,7 +1027,12 @@ public class RoomsModerationController(DorkNetDbContext db) : ControllerBase
|
|||||||
IsStudioRoom = source.IsStudioRoom,
|
IsStudioRoom = source.IsStudioRoom,
|
||||||
IsRoomLinkedToRecRoomStudio = source.IsRoomLinkedToRecRoomStudio,
|
IsRoomLinkedToRecRoomStudio = source.IsRoomLinkedToRecRoomStudio,
|
||||||
StudioSessionId = source.StudioSessionId,
|
StudioSessionId = source.StudioSessionId,
|
||||||
TagsCsv = source.TagsCsv,
|
// A user clone is community content — NOT an RRO. Copying the
|
||||||
|
// source's tags verbatim would keep "recroomoriginal,quest,…" on
|
||||||
|
// the clone, making the client treat it as a quest room (wrong
|
||||||
|
// badge, and quest-instance logic can boot the player). Match
|
||||||
|
// RoomService.CloneAsync and tag it "community".
|
||||||
|
TagsCsv = "community",
|
||||||
CurrentDataBlobName = source.CurrentDataBlobName,
|
CurrentDataBlobName = source.CurrentDataBlobName,
|
||||||
};
|
};
|
||||||
db.Rooms.Add(clone);
|
db.Rooms.Add(clone);
|
||||||
|
|||||||
@@ -1810,6 +1810,32 @@ public class AdminController(
|
|||||||
return Ok(doors);
|
return Ok(doors);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>GET <c>api/admin/v1/settings/base-rooms</c> — the curated
|
||||||
|
/// room-creation base-room list (ordered room names) plus the catalogue of
|
||||||
|
/// Rec Room Original rooms the SPA can offer as pickable candidates.</summary>
|
||||||
|
[HttpGet("settings/base-rooms")]
|
||||||
|
public async Task<ActionResult> GetBaseRooms()
|
||||||
|
{
|
||||||
|
var names = await serverSettings.GetBaseRoomNamesAsync();
|
||||||
|
var available = await db.Rooms
|
||||||
|
.Where(r => EF.Functions.Like(r.TagsCsv, "%recroomoriginal%"))
|
||||||
|
.OrderBy(r => r.Name)
|
||||||
|
.Select(r => new { r.Name, r.ImageName, r.TagsCsv })
|
||||||
|
.ToListAsync();
|
||||||
|
return Ok(new { Names = names, Available = available });
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record BaseRoomsRequest(List<string>? Names);
|
||||||
|
|
||||||
|
[HttpPost("settings/base-rooms")]
|
||||||
|
public async Task<ActionResult> SetBaseRooms([FromBody] BaseRoomsRequest body)
|
||||||
|
{
|
||||||
|
var names = await serverSettings.SetBaseRoomNamesAsync(body.Names ?? []);
|
||||||
|
await LogAsync("base_rooms_updated", "system", 0, string.Join(",", names));
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return Ok(new { Names = names });
|
||||||
|
}
|
||||||
|
|
||||||
[HttpGet("settings/gameconfigs")]
|
[HttpGet("settings/gameconfigs")]
|
||||||
public async Task<ActionResult> GetDiscoveredGameConfigs()
|
public async Task<ActionResult> GetDiscoveredGameConfigs()
|
||||||
=> Ok(await serverSettings.GetDiscoveredGameConfigsAsync());
|
=> Ok(await serverSettings.GetDiscoveredGameConfigsAsync());
|
||||||
|
|||||||
@@ -92,5 +92,11 @@ public class ServerSettingsEntity
|
|||||||
/// redeploy.</summary>
|
/// redeploy.</summary>
|
||||||
public string CharadesSlotBindingsJson { get; set; } = string.Empty;
|
public string CharadesSlotBindingsJson { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Ordered list of room NAMES shown in the room-creation "base
|
||||||
|
/// room" picker (<c>rooms/base</c> / <c>api/rooms/v*/baserooms</c>), as a
|
||||||
|
/// JSON string array. Empty falls back to the built-in default set. Lets
|
||||||
|
/// admins curate the base-room list from the SPA without a redeploy.</summary>
|
||||||
|
public string BaseRoomNamesJson { get; set; } = string.Empty;
|
||||||
|
|
||||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,6 +106,64 @@ public class ServerSettingsService(DorkNetDbContext db)
|
|||||||
return ToRecCenterDoors(row);
|
return ToRecCenterDoors(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Built-in default base-room picker (used when an admin hasn't
|
||||||
|
/// customised the list). RRO originals minus #quest rooms, MakerRoom
|
||||||
|
/// first as the canonical blank canvas.</summary>
|
||||||
|
public static readonly IReadOnlyList<string> DefaultBaseRoomNames = new[]
|
||||||
|
{
|
||||||
|
"MakerRoom", "RecCenter", "3DCharades", "Dodgeball",
|
||||||
|
"DiscGolfLake", "DiscGolfPropulsion", "Paddleball",
|
||||||
|
"Paintball", "Soccer", "LaserTag", "RecRoyaleSquads",
|
||||||
|
"RecRoyaleSolos", "Park", "BowlingAlley", "RecRally",
|
||||||
|
};
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<string>> GetBaseRoomNamesAsync()
|
||||||
|
{
|
||||||
|
var row = await GetAsync();
|
||||||
|
return ToBaseRoomNames(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<string>> SetBaseRoomNamesAsync(IReadOnlyList<string> names)
|
||||||
|
{
|
||||||
|
var normalized = NormalizeBaseRoomNames(names);
|
||||||
|
var existing = await GetTrackedRowAsync();
|
||||||
|
existing.BaseRoomNamesJson = JsonSerializer.Serialize(normalized, JsonOptions);
|
||||||
|
existing.UpdatedAt = DateTime.UtcNow;
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<string> ToBaseRoomNames(ServerSettingsEntity row)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(row.BaseRoomNamesJson))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var parsed = JsonSerializer.Deserialize<List<string>>(row.BaseRoomNamesJson, JsonOptions);
|
||||||
|
if (parsed is { Count: > 0 })
|
||||||
|
return NormalizeBaseRoomNames(parsed);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Bad settings must not break room creation; admins can
|
||||||
|
// overwrite from the SPA.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return DefaultBaseRoomNames.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<string> NormalizeBaseRoomNames(IReadOnlyList<string> names)
|
||||||
|
{
|
||||||
|
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var result = new List<string>();
|
||||||
|
foreach (var n in names)
|
||||||
|
{
|
||||||
|
var t = (n ?? string.Empty).Trim();
|
||||||
|
if (t.Length > 0 && seen.Add(t)) result.Add(t);
|
||||||
|
}
|
||||||
|
return result.Count > 0 ? result : DefaultBaseRoomNames.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<DiscoveredGameConfigSettings> GetDiscoveredGameConfigsAsync()
|
public async Task<DiscoveredGameConfigSettings> GetDiscoveredGameConfigsAsync()
|
||||||
{
|
{
|
||||||
var row = await GetAsync();
|
var row = await GetAsync();
|
||||||
|
|||||||
@@ -531,6 +531,8 @@ public static class DatabaseBootstrap
|
|||||||
@"ALTER TABLE ""ServerSettings"" ADD COLUMN IF NOT EXISTS ""CharadesSlotBindingsJson"" text NOT NULL DEFAULT '';");
|
@"ALTER TABLE ""ServerSettings"" ADD COLUMN IF NOT EXISTS ""CharadesSlotBindingsJson"" text NOT NULL DEFAULT '';");
|
||||||
await db.Database.ExecuteSqlRawAsync(
|
await db.Database.ExecuteSqlRawAsync(
|
||||||
@"ALTER TABLE ""ServerSettings"" ADD COLUMN IF NOT EXISTS ""AllAvatarItemsOwned"" boolean NOT NULL DEFAULT false;");
|
@"ALTER TABLE ""ServerSettings"" ADD COLUMN IF NOT EXISTS ""AllAvatarItemsOwned"" boolean NOT NULL DEFAULT false;");
|
||||||
|
await db.Database.ExecuteSqlRawAsync(
|
||||||
|
@"ALTER TABLE ""ServerSettings"" ADD COLUMN IF NOT EXISTS ""BaseRoomNamesJson"" text NOT NULL DEFAULT '';");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -617,6 +619,18 @@ public static class DatabaseBootstrap
|
|||||||
// ignore the duplicate-column error; any other schema problem
|
// ignore the duplicate-column error; any other schema problem
|
||||||
// will surface when EF reads ServerSettings.
|
// will surface when EF reads ServerSettings.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await db.Database.ExecuteSqlRawAsync(
|
||||||
|
@"ALTER TABLE ""ServerSettings"" ADD COLUMN ""BaseRoomNamesJson"" 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.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task EnsureInventorySchemaAsync(DorkNetDbContext db)
|
private static async Task EnsureInventorySchemaAsync(DorkNetDbContext db)
|
||||||
|
|||||||
@@ -28,6 +28,17 @@ interface RecCenterDoorSettings {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface BaseRoomOption {
|
||||||
|
name: string;
|
||||||
|
imageName: string;
|
||||||
|
tagsCsv: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BaseRoomsResponse {
|
||||||
|
names: string[];
|
||||||
|
available: BaseRoomOption[];
|
||||||
|
}
|
||||||
|
|
||||||
export function Rooms() {
|
export function Rooms() {
|
||||||
const { data, loading, error, refresh } = useApi<Room[]>('/rooms');
|
const { data, loading, error, refresh } = useApi<Room[]>('/rooms');
|
||||||
const {
|
const {
|
||||||
@@ -42,6 +53,12 @@ export function Rooms() {
|
|||||||
error: doorsError,
|
error: doorsError,
|
||||||
refresh: refreshRecCenterDoors,
|
refresh: refreshRecCenterDoors,
|
||||||
} = useApi<RecCenterDoorSettings>('/settings/rec-center-doors');
|
} = useApi<RecCenterDoorSettings>('/settings/rec-center-doors');
|
||||||
|
const {
|
||||||
|
data: baseRooms,
|
||||||
|
loading: baseRoomsLoading,
|
||||||
|
error: baseRoomsError,
|
||||||
|
refresh: refreshBaseRooms,
|
||||||
|
} = useApi<BaseRoomsResponse>('/settings/base-rooms');
|
||||||
const [filter, setFilter] = useState('');
|
const [filter, setFilter] = useState('');
|
||||||
const [filterKind, setFilterKind] = useState<'all' | 'original' | 'custom' | 'dorm'>('all');
|
const [filterKind, setFilterKind] = useState<'all' | 'original' | 'custom' | 'dorm'>('all');
|
||||||
const [pendingDelete, setPendingDelete] = useState<Room | null>(null);
|
const [pendingDelete, setPendingDelete] = useState<Room | null>(null);
|
||||||
@@ -118,6 +135,13 @@ export function Rooms() {
|
|||||||
onSaved={refreshRecCenterDoors}
|
onSaved={refreshRecCenterDoors}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<BaseRoomsPanel
|
||||||
|
data={baseRooms}
|
||||||
|
loading={baseRoomsLoading}
|
||||||
|
error={baseRoomsError}
|
||||||
|
onSaved={refreshBaseRooms}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="card overflow-hidden">
|
<div className="card overflow-hidden">
|
||||||
<div className="flex flex-wrap items-center gap-2 border-b border-ink-800 px-3 py-2.5">
|
<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" />
|
<input value={filter} onChange={e => setFilter(e.target.value)} placeholder="Filter by name…" className="input max-w-xs" />
|
||||||
@@ -485,6 +509,132 @@ function RecCenterDoorsPanel({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function BaseRoomsPanel({
|
||||||
|
data,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
onSaved,
|
||||||
|
}: {
|
||||||
|
data: BaseRoomsResponse | null;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
onSaved: () => void;
|
||||||
|
}) {
|
||||||
|
const [names, setNames] = useState<string[]>([]);
|
||||||
|
const [toAdd, setToAdd] = useState('');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!data) return;
|
||||||
|
setNames(data.names);
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const available = data?.available ?? [];
|
||||||
|
const notAdded = available.filter(a => !names.some(n => n.toLowerCase() === a.name.toLowerCase()));
|
||||||
|
|
||||||
|
const move = (i: number, dir: -1 | 1) => {
|
||||||
|
setNames(cur => {
|
||||||
|
const j = i + dir;
|
||||||
|
if (j < 0 || j >= cur.length) return cur;
|
||||||
|
const next = [...cur];
|
||||||
|
[next[i], next[j]] = [next[j], next[i]];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const remove = (i: number) => setNames(cur => cur.filter((_, idx) => idx !== i));
|
||||||
|
const add = (name: string) => {
|
||||||
|
if (!name) return;
|
||||||
|
setNames(cur => (cur.some(n => n.toLowerCase() === name.toLowerCase()) ? cur : [...cur, name]));
|
||||||
|
setToAdd('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const save = async (reset = false) => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await api<{ names: string[] }>('/settings/base-rooms', {
|
||||||
|
method: 'POST',
|
||||||
|
body: reset ? { Names: [] } : { Names: names },
|
||||||
|
});
|
||||||
|
toast.push(reset ? 'Base rooms reset to defaults.' : 'Base rooms 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">Base rooms</h2>
|
||||||
|
<p className="mt-1 text-xs text-ink-400">
|
||||||
|
Ordered rooms shown in the room-creation "base room" picker (<code className="font-mono text-[11px]">rooms/base</code>).
|
||||||
|
MakerRoom is the blank canvas; quests are excluded by default.
|
||||||
|
</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 base rooms'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="mt-3 text-sm text-danger">{error}</div>}
|
||||||
|
{loading && !data && <div className="mt-3 text-xs text-ink-400">Loading base rooms...</div>}
|
||||||
|
{data && (
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<select
|
||||||
|
value={toAdd}
|
||||||
|
onChange={e => setToAdd(e.target.value)}
|
||||||
|
className="input max-w-xs text-xs"
|
||||||
|
>
|
||||||
|
<option value="">Add a room…</option>
|
||||||
|
{notAdded.map(a => (
|
||||||
|
<option key={a.name} value={a.name}>{a.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button onClick={() => add(toAdd)} disabled={!toAdd} className="btn-secondary text-xs">
|
||||||
|
<Plus /> Add
|
||||||
|
</button>
|
||||||
|
<span className="ml-auto text-[11px] text-ink-500">{names.length} rooms</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ol className="space-y-1">
|
||||||
|
{names.map((name, i) => {
|
||||||
|
const missing = !available.some(a => a.name.toLowerCase() === name.toLowerCase());
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={`${name}-${i}`}
|
||||||
|
className="flex items-center gap-2 rounded-md border border-ink-800 bg-ink-950/30 px-2 py-1.5"
|
||||||
|
>
|
||||||
|
<span className="w-6 text-right text-[11px] tabular-nums text-ink-500">{i + 1}</span>
|
||||||
|
<span className={`font-mono text-xs ${missing ? 'text-danger' : 'text-ink-100'}`}>{name}</span>
|
||||||
|
{missing && <span className="badge-neutral text-[10px]">not found</span>}
|
||||||
|
<div className="ml-auto flex items-center gap-1">
|
||||||
|
<button onClick={() => move(i, -1)} disabled={i === 0} className="btn-ghost text-xs" title="Move up">↑</button>
|
||||||
|
<button onClick={() => move(i, 1)} disabled={i === names.length - 1} className="btn-ghost text-xs" title="Move down">↓</button>
|
||||||
|
<button onClick={() => remove(i)} className="btn-ghost text-xs text-danger" title="Remove"><Trash /></button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{names.length === 0 && (
|
||||||
|
<li className="text-xs text-ink-500">No base rooms set. Defaults will be used on save.</li>
|
||||||
|
)}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function TagListEditor({
|
function TagListEditor({
|
||||||
label,
|
label,
|
||||||
tags,
|
tags,
|
||||||
|
|||||||
Reference in New Issue
Block a user