mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
[www] subroom listing
This commit is contained in:
+313
-40
@@ -60,9 +60,36 @@ interface SelfAccount {
|
||||
}
|
||||
|
||||
/**
|
||||
* One room from `rooms` (`GET /rooms/ownedby/me`), narrowed to what this page draws. The
|
||||
* worker serves the stored room blob verbatim — dozens of fields the game needs and this
|
||||
* list doesn't — so only the handful read here is declared.
|
||||
* One subroom, as `rooms` re-attaches them to every room read. A room is a container;
|
||||
* the subrooms are the actual places players load into, each with its own accessibility
|
||||
* and its own save history.
|
||||
*/
|
||||
interface SubRoom {
|
||||
SubRoomId: number
|
||||
Name: string
|
||||
/** The Unity scene the client loads under the saved objects. */
|
||||
UnitySceneId: string
|
||||
/** Set INDEPENDENTLY of the room's — a public room can hold a private subroom. */
|
||||
Accessibility: number
|
||||
IsSandbox: boolean
|
||||
MaxPlayers: number
|
||||
/**
|
||||
* A save posted without `AutoPublish` waits here. Cleared when that save is
|
||||
* published, so a non-null value means "edited since players last saw a change".
|
||||
*/
|
||||
StagedSubRoomDataSaveId: number | null
|
||||
/**
|
||||
* What players actually load. Null until the first publish — and a subroom without
|
||||
* one silently loads nothing, which is worth surfacing to an owner who can't tell
|
||||
* that apart from a broken room.
|
||||
*/
|
||||
CurrentSave: { SubRoomDataSaveId: number; CreatedAt: string; Description: string } | null
|
||||
}
|
||||
|
||||
/**
|
||||
* One room from `rooms` (`GET /rooms/ownedby/me`), narrowed to what these pages draw.
|
||||
* The worker serves the stored room blob verbatim — dozens of fields the game needs and
|
||||
* the website doesn't — so only the ones read here are declared.
|
||||
*/
|
||||
interface OwnedRoom {
|
||||
RoomId: number
|
||||
@@ -73,6 +100,18 @@ interface OwnedRoom {
|
||||
/** The `Accessibility` ordinal, NOT the enum name — see ACCESSIBILITY_LABEL. */
|
||||
Accessibility: number
|
||||
CreatedAt: string
|
||||
MaxPlayers: number
|
||||
/** False blocks `POST /rooms/{id}/clone` — nobody can take a copy of the room. */
|
||||
CloningAllowed: boolean
|
||||
SupportsScreens: boolean
|
||||
SupportsWalkVR: boolean
|
||||
SupportsTeleportVR: boolean
|
||||
SupportsQuest2: boolean
|
||||
SupportsMobile: boolean
|
||||
SupportsJuniors: boolean
|
||||
/** `Type` 0 is a tag the owner set, 2 one the server derived. */
|
||||
Tags: Array<{ Tag: string; Type: number }>
|
||||
SubRooms: SubRoom[]
|
||||
/** Always present: the worker folds the live counters in on every read. */
|
||||
Stats: {
|
||||
CheerCount: number
|
||||
@@ -83,9 +122,9 @@ interface OwnedRoom {
|
||||
}
|
||||
|
||||
/**
|
||||
* What a room's `Accessibility` ordinal is called on screen. The two dev values are
|
||||
* reachable — the game sets them — so they're named rather than left to fall through
|
||||
* to the unknown case in RoomCard.
|
||||
* What an `Accessibility` ordinal is called on screen — rooms and subrooms both carry
|
||||
* one. The two dev values are reachable (the game sets them), so they're named rather
|
||||
* than left to fall through to the unknown case in `accessibilityLabel`.
|
||||
*/
|
||||
const ACCESSIBILITY_LABEL: Record<number, string> = {
|
||||
[Accessibility.Private]: 'Private',
|
||||
@@ -414,6 +453,16 @@ function Link({
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The room id in `/rooms/<id>`, or null for any other path. Numeric rather than the
|
||||
* room's name: a name is renameable (`PUT /rooms/{id}/name`), so a link someone
|
||||
* bookmarked would rot the moment they renamed the room.
|
||||
*/
|
||||
function roomIdFromPath(path: string): number | null {
|
||||
const match = /^\/rooms\/(\d+)$/.exec(path)
|
||||
return match ? Number.parseInt(match[1], 10) : null
|
||||
}
|
||||
|
||||
export function App() {
|
||||
// undefined = still checking the session; null = signed out.
|
||||
const [account, setAccount] = useState<SelfAccount | null | undefined>(undefined)
|
||||
@@ -421,6 +470,7 @@ export function App() {
|
||||
// so a slow (or failed) config fetch can't flash a form the server would refuse.
|
||||
const [config, setConfig] = useState<SiteConfig | undefined>(undefined)
|
||||
const { path, navigate } = useRouter()
|
||||
const roomId = roomIdFromPath(path)
|
||||
|
||||
useEffect(() => {
|
||||
// Config first, and everything else after it: it carries the hostnames every other
|
||||
@@ -469,6 +519,8 @@ export function App() {
|
||||
/>
|
||||
) : path === '/account' ? (
|
||||
<AccountPage account={account} navigate={navigate} onChange={setAccount} />
|
||||
) : roomId !== null ? (
|
||||
<RoomPage account={account} roomId={roomId} navigate={navigate} />
|
||||
) : (
|
||||
<HomePage account={account} config={config} navigate={navigate} />
|
||||
)}
|
||||
@@ -891,11 +943,224 @@ function AccountPage({
|
||||
return (
|
||||
<main className="shell wide">
|
||||
<h1>My account</h1>
|
||||
<Dashboard account={account} onChange={onChange} />
|
||||
<Dashboard account={account} navigate={navigate} onChange={onChange} />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One room's own page — what it is, how it's set up, and the subrooms inside it.
|
||||
*
|
||||
* The room is found in the caller's OWN list rather than read from the public
|
||||
* `GET /rooms?id=`, which is unfiltered by design (the game looks any room up that way).
|
||||
* Going through `ownedby/me` is what makes this the owner's page: a room that isn't
|
||||
* yours simply isn't in the list, so there's no second ownership rule here to drift out
|
||||
* of step with the one the mutating endpoints enforce.
|
||||
*/
|
||||
function RoomPage({
|
||||
account,
|
||||
roomId,
|
||||
navigate,
|
||||
}: {
|
||||
account: SelfAccount | null | undefined
|
||||
roomId: number
|
||||
navigate: Navigate
|
||||
}) {
|
||||
const [rooms, setRooms] = useState<OwnedRoom[] | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
const accountId = account?.accountId
|
||||
|
||||
useEffect(() => {
|
||||
if (account === null) navigate('/login')
|
||||
}, [account, navigate])
|
||||
|
||||
useEffect(() => {
|
||||
// Waits for the session: the list is auth-gated, and `account === undefined` only
|
||||
// means the stored token hasn't been checked yet.
|
||||
if (accountId === undefined) return
|
||||
void fetchMyRooms()
|
||||
.then(setRooms)
|
||||
.catch((e) => setError(e instanceof Error ? e.message : String(e)))
|
||||
}, [accountId])
|
||||
|
||||
if (!account) {
|
||||
return (
|
||||
<main className="shell">
|
||||
<p className="muted">{account === undefined ? 'Loading…' : 'Redirecting…'}</p>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
const room = rooms?.find((r) => r.RoomId === roomId)
|
||||
|
||||
return (
|
||||
<main className="shell wide">
|
||||
<p className="backlink">
|
||||
<Link to="/account" navigate={navigate}>
|
||||
← My rooms
|
||||
</Link>
|
||||
</p>
|
||||
{error ? (
|
||||
<p className="error">{error}</p>
|
||||
) : rooms === null ? (
|
||||
<p className="muted">Loading…</p>
|
||||
) : room === undefined ? (
|
||||
// Covers both "no such room" and "someone else's" — deliberately the same
|
||||
// sentence, since telling a stranger which of the two it is answers a question
|
||||
// they have no business asking.
|
||||
<p className="muted">That isn't one of your rooms.</p>
|
||||
) : (
|
||||
<RoomDetail room={room} imgHost={where().img} />
|
||||
)}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
/** The platforms a room says it supports, named the way the game names them. */
|
||||
function platformList(room: OwnedRoom): string[] {
|
||||
const on: string[] = []
|
||||
if (room.SupportsScreens) on.push('Screens')
|
||||
if (room.SupportsWalkVR) on.push('VR (walk)')
|
||||
if (room.SupportsTeleportVR) on.push('VR (teleport)')
|
||||
if (room.SupportsQuest2) on.push('Quest 2')
|
||||
if (room.SupportsMobile) on.push('Mobile')
|
||||
if (room.SupportsJuniors) on.push('Juniors')
|
||||
return on
|
||||
}
|
||||
|
||||
/** A room's settings and its subrooms. Read-only: rooms are edited in game. */
|
||||
function RoomDetail({ room, imgHost }: { room: OwnedRoom; imgHost: string }) {
|
||||
const created = new Date(room.CreatedAt)
|
||||
const platforms = platformList(room)
|
||||
const subRooms = room.SubRooms ?? []
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="card room-hero">
|
||||
{/* 512 rather than the list's 256: this one is displayed large. Both are sizes
|
||||
the img worker allows, so each is a cached variant. */}
|
||||
<img className="room-hero-img" src={`${imgHost}/${room.ImageName}?width=512`} alt="" />
|
||||
<div className="room-hero-body">
|
||||
<div className="room-head">
|
||||
<h1 className="room-hero-name">^{room.Name}</h1>
|
||||
<VisibilityBadge accessibility={room.Accessibility} />
|
||||
</div>
|
||||
{room.Description ? (
|
||||
<p className="muted room-hero-desc">{room.Description}</p>
|
||||
) : (
|
||||
<p className="muted room-hero-desc">No description set.</p>
|
||||
)}
|
||||
<p className="room-stats">
|
||||
{room.Stats.VisitCount.toLocaleString()} visit
|
||||
{room.Stats.VisitCount === 1 ? '' : 's'} · {room.Stats.FavoriteCount.toLocaleString()}{' '}
|
||||
favourite
|
||||
{room.Stats.FavoriteCount === 1 ? '' : 's'} · {room.Stats.CheerCount.toLocaleString()}{' '}
|
||||
cheer{room.Stats.CheerCount === 1 ? '' : 's'}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="card">
|
||||
<h2>Settings</h2>
|
||||
<dl className="facts">
|
||||
<dt>Room id</dt>
|
||||
<dd>{room.RoomId}</dd>
|
||||
<dt>Visibility</dt>
|
||||
<dd>{accessibilityLabel(room.Accessibility)}</dd>
|
||||
<dt>Max players</dt>
|
||||
<dd>{room.MaxPlayers}</dd>
|
||||
<dt>Cloning</dt>
|
||||
<dd>
|
||||
{room.CloningAllowed ? 'Anyone may clone this room' : 'Nobody may clone this room'}
|
||||
</dd>
|
||||
<dt>Plays on</dt>
|
||||
<dd>
|
||||
{platforms.length > 0 ? platforms.join(', ') : 'Nothing — no platform is enabled'}
|
||||
</dd>
|
||||
<dt>Tags</dt>
|
||||
<dd>{room.Tags?.length ? room.Tags.map((t) => t.Tag).join(', ') : 'None'}</dd>
|
||||
<dt>Created</dt>
|
||||
<dd>{Number.isNaN(created.getTime()) ? room.CreatedAt : created.toLocaleDateString()}</dd>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section className="card">
|
||||
<h2>Subrooms</h2>
|
||||
<p className="muted">
|
||||
The places inside the room players actually load into. Each keeps its own accessibility
|
||||
and its own saves, so a public room can still hold a subroom nobody else can reach.
|
||||
</p>
|
||||
{subRooms.length === 0 ? (
|
||||
<p className="muted">This room has no subrooms.</p>
|
||||
) : (
|
||||
<ul className="subrooms">
|
||||
{subRooms.map((sub) => (
|
||||
<SubRoomRow key={sub.SubRoomId} sub={sub} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** One subroom: what it is, and — the part an owner can't see anywhere else — its save. */
|
||||
function SubRoomRow({ sub }: { sub: SubRoom }) {
|
||||
const save = sub.CurrentSave ?? null
|
||||
const saved = save ? new Date(save.CreatedAt) : null
|
||||
// Cleared when that save is published (see publishSubRoomSave), so a value here always
|
||||
// means work the owner saved but players still can't see.
|
||||
const staged = sub.StagedSubRoomDataSaveId !== null && sub.StagedSubRoomDataSaveId !== undefined
|
||||
|
||||
return (
|
||||
<li className="subroom">
|
||||
<div className="room-head">
|
||||
<span className="subroom-name">{sub.Name || `Subroom ${sub.SubRoomId}`}</span>
|
||||
<VisibilityBadge accessibility={sub.Accessibility} />
|
||||
{sub.IsSandbox && <span className="badge">Sandbox</span>}
|
||||
</div>
|
||||
<p className="subroom-meta">
|
||||
#{sub.SubRoomId} · up to {sub.MaxPlayers} players
|
||||
{sub.UnitySceneId && ` · scene ${sub.UnitySceneId}`}
|
||||
</p>
|
||||
<p className="subroom-save">
|
||||
{save === null ? (
|
||||
// A subroom with no published save loads an empty scene without erroring, which
|
||||
// from the inside looks exactly like a broken room. Say so plainly.
|
||||
<span className="warn">Never published — players load an empty scene.</span>
|
||||
) : (
|
||||
<>
|
||||
Published save #{save.SubRoomDataSaveId}
|
||||
{saved && !Number.isNaN(saved.getTime()) && `, saved ${saved.toLocaleString()}`}
|
||||
{save.Description && ` — “${save.Description}”`}
|
||||
</>
|
||||
)}
|
||||
{staged && (
|
||||
<span className="warn"> · a newer save is staged, waiting to be published.</span>
|
||||
)}
|
||||
</p>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
/** How a room or subroom's `Accessibility` reads on screen. */
|
||||
const accessibilityLabel = (accessibility: number): string =>
|
||||
// Unknown ordinals shouldn't happen, but this label is the only thing telling an owner
|
||||
// whether a room is visible — so show the raw value rather than nothing at all.
|
||||
ACCESSIBILITY_LABEL[accessibility] ?? `Accessibility ${accessibility}`
|
||||
|
||||
/**
|
||||
* The visibility pill. Public gets the same green "healthy" reading as the server
|
||||
* status; every other value stays neutral, since Private is a choice, not a fault.
|
||||
*/
|
||||
function VisibilityBadge({ accessibility }: { accessibility: number }) {
|
||||
return (
|
||||
<span className={`badge ${accessibility === Accessibility.Public ? 'live' : ''}`}>
|
||||
{accessibilityLabel(accessibility)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/** Small hook wrapping a submit handler with pending/error/success state. */
|
||||
function useAction() {
|
||||
const [pending, setPending] = useState(false)
|
||||
@@ -1152,9 +1417,11 @@ function LoginForm({ onAuthed }: { onAuthed: (a: SelfAccount) => void }) {
|
||||
|
||||
function Dashboard({
|
||||
account,
|
||||
navigate,
|
||||
onChange,
|
||||
}: {
|
||||
account: SelfAccount
|
||||
navigate: Navigate
|
||||
onChange: (a: SelfAccount) => void
|
||||
}) {
|
||||
// The dashboard sections, shown one at a time via the left tab rail. Admin-only
|
||||
@@ -1162,7 +1429,7 @@ function Dashboard({
|
||||
const sections = [
|
||||
// First, so a player who just signed in lands on what they made rather than on a
|
||||
// settings form they opened the page to avoid.
|
||||
{ id: 'rooms', label: 'My rooms', render: () => <MyRooms /> },
|
||||
{ id: 'rooms', label: 'My rooms', render: () => <MyRooms navigate={navigate} /> },
|
||||
{
|
||||
id: 'username',
|
||||
label: 'Username',
|
||||
@@ -1219,7 +1486,7 @@ function Dashboard({
|
||||
* the overview — everything you've made in one place, including the rooms you never
|
||||
* published, which are invisible everywhere else.
|
||||
*/
|
||||
function MyRooms() {
|
||||
function MyRooms({ navigate }: { navigate: Navigate }) {
|
||||
const [rooms, setRooms] = useState<OwnedRoom[] | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
@@ -1251,7 +1518,7 @@ function MyRooms() {
|
||||
// the fetch above resolved, and that fetch went through `where()` itself.
|
||||
<ul className="rooms">
|
||||
{rooms.map((room) => (
|
||||
<RoomCard key={room.RoomId} room={room} imgHost={where().img} />
|
||||
<RoomCard key={room.RoomId} room={room} imgHost={where().img} navigate={navigate} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
@@ -1261,47 +1528,53 @@ function MyRooms() {
|
||||
|
||||
/**
|
||||
* One room in the list: its thumbnail, what it's called in game (`^Name`), and how it's
|
||||
* doing.
|
||||
* doing. The whole row links to the room's own page.
|
||||
*
|
||||
* The thumbnail is asked for at 256px wide — one of the img worker's four allowed sizes,
|
||||
* so it's a cached variant rather than the full-size upload. A room with no image of its
|
||||
* own still answers 200 there (the worker serves its fallback), so there's no broken
|
||||
* frame to handle.
|
||||
*/
|
||||
function RoomCard({ room, imgHost }: { room: OwnedRoom; imgHost: string }) {
|
||||
// Unknown ordinals shouldn't happen, but the label is the only thing telling an owner
|
||||
// whether a room is visible — so show the raw value rather than nothing at all.
|
||||
const visibility =
|
||||
ACCESSIBILITY_LABEL[room.Accessibility] ?? `Accessibility ${room.Accessibility}`
|
||||
function RoomCard({
|
||||
room,
|
||||
imgHost,
|
||||
navigate,
|
||||
}: {
|
||||
room: OwnedRoom
|
||||
imgHost: string
|
||||
navigate: Navigate
|
||||
}) {
|
||||
const created = new Date(room.CreatedAt)
|
||||
|
||||
return (
|
||||
<li className="room">
|
||||
<img
|
||||
className="room-thumb"
|
||||
src={`${imgHost}/${room.ImageName}?width=256`}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className="room-body">
|
||||
<div className="room-head">
|
||||
{/* The caret is how the game writes a room name, so it reads as the thing you
|
||||
type to get there rather than as a title someone wrote. */}
|
||||
<span className="room-name">^{room.Name}</span>
|
||||
<span className={`badge ${room.Accessibility === Accessibility.Public ? 'live' : ''}`}>
|
||||
{visibility}
|
||||
</span>
|
||||
{/* A real `<a href>` (see Link), not a click handler on the row: it has to be
|
||||
reachable by keyboard, and openable in a new tab like any other link. */}
|
||||
<Link to={`/rooms/${room.RoomId}`} navigate={navigate} className="room-link">
|
||||
<img
|
||||
className="room-thumb"
|
||||
src={`${imgHost}/${room.ImageName}?width=256`}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className="room-body">
|
||||
<div className="room-head">
|
||||
{/* The caret is how the game writes a room name, so it reads as the thing you
|
||||
type to get there rather than as a title someone wrote. */}
|
||||
<span className="room-name">^{room.Name}</span>
|
||||
<VisibilityBadge accessibility={room.Accessibility} />
|
||||
</div>
|
||||
{room.Description && <p className="room-desc">{room.Description}</p>}
|
||||
<p className="room-stats">
|
||||
{room.Stats.VisitCount.toLocaleString()} visit
|
||||
{room.Stats.VisitCount === 1 ? '' : 's'} · {room.Stats.FavoriteCount.toLocaleString()}{' '}
|
||||
favourite
|
||||
{room.Stats.FavoriteCount === 1 ? '' : 's'} · {room.Stats.CheerCount.toLocaleString()}{' '}
|
||||
cheer{room.Stats.CheerCount === 1 ? '' : 's'}
|
||||
{!Number.isNaN(created.getTime()) && ` · made ${created.toLocaleDateString()}`}
|
||||
</p>
|
||||
</div>
|
||||
{room.Description && <p className="room-desc">{room.Description}</p>}
|
||||
<p className="room-stats">
|
||||
{room.Stats.VisitCount.toLocaleString()} visit
|
||||
{room.Stats.VisitCount === 1 ? '' : 's'} · {room.Stats.FavoriteCount.toLocaleString()}{' '}
|
||||
favourite
|
||||
{room.Stats.FavoriteCount === 1 ? '' : 's'} · {room.Stats.CheerCount.toLocaleString()}{' '}
|
||||
cheer{room.Stats.CheerCount === 1 ? '' : 's'}
|
||||
{!Number.isNaN(created.getTime()) && ` · made ${created.toLocaleDateString()}`}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -622,10 +622,6 @@ h2 {
|
||||
}
|
||||
|
||||
.room {
|
||||
display: grid;
|
||||
grid-template-columns: 112px minmax(0, 1fr);
|
||||
gap: 14px;
|
||||
align-items: start;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
@@ -636,6 +632,29 @@ h2 {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
/* The whole row is the link to the room's page, so the target is the size of the thing
|
||||
you're aiming at rather than the name alone. Padded out to the card edge and pulled
|
||||
back by the same amount, so the hover fill covers the row instead of stopping short. */
|
||||
.room-link {
|
||||
display: grid;
|
||||
grid-template-columns: 112px minmax(0, 1fr);
|
||||
gap: 14px;
|
||||
align-items: start;
|
||||
margin: 0 -10px;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.room-link:hover {
|
||||
background: var(--surface-hi);
|
||||
}
|
||||
|
||||
.room-link:hover .room-name {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* Fixed 3:2 frame — room thumbnails are screenshots and arrive at any ratio, and a
|
||||
per-room height would leave the names in a ragged column. */
|
||||
.room-thumb {
|
||||
@@ -707,6 +726,115 @@ h2 {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ---- One room's page ---------------------------------------------------- */
|
||||
|
||||
/* The way back out, above the page's own heading. */
|
||||
.backlink {
|
||||
margin: 0 0 16px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.backlink a {
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.backlink a:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Wider thumbnail than the list's, and the name beside it rather than under: this is
|
||||
the page about this one room, so the photo can carry some of the weight. */
|
||||
.room-hero {
|
||||
display: grid;
|
||||
grid-template-columns: 260px minmax(0, 1fr);
|
||||
gap: 20px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.room-hero-img {
|
||||
width: 100%;
|
||||
aspect-ratio: 3 / 2;
|
||||
object-fit: cover;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--surface-hi);
|
||||
}
|
||||
|
||||
.room-hero-name {
|
||||
font-family: var(--display);
|
||||
font-weight: 700;
|
||||
font-size: 1.5rem;
|
||||
letter-spacing: -0.02em;
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.room-hero-desc {
|
||||
margin: 10px 0 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* Label/value pairs. A two-column grid rather than a list of "Label: value" lines, so
|
||||
the values line up and the page can be read down one column. */
|
||||
.facts {
|
||||
display: grid;
|
||||
grid-template-columns: 150px minmax(0, 1fr);
|
||||
gap: 8px 16px;
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.facts dt {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.facts dd {
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.subrooms {
|
||||
list-style: none;
|
||||
margin: 18px 0 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.subroom {
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.subroom:first-child {
|
||||
padding-top: 0;
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.subroom-name {
|
||||
font-family: var(--display);
|
||||
font-weight: 700;
|
||||
font-size: 0.975rem;
|
||||
letter-spacing: -0.01em;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.subroom-meta,
|
||||
.subroom-save {
|
||||
margin: 6px 0 0;
|
||||
font-size: 0.825rem;
|
||||
color: var(--muted);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* Something the owner probably wants to act on — an unpublished subroom, a staged save
|
||||
nobody can see yet. Not an error: nothing is broken, it just isn't live. */
|
||||
.warn {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* ---- Forms -------------------------------------------------------------- */
|
||||
|
||||
label {
|
||||
@@ -882,6 +1010,21 @@ button[type='submit']:disabled {
|
||||
.vtabs button {
|
||||
border-color: var(--line);
|
||||
}
|
||||
|
||||
/* The room page's hero and its fact table both stack: 260px of photo (or 150px of
|
||||
label) beside a value leaves the value in a column too narrow to read. */
|
||||
.room-hero {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.facts {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 2px 0;
|
||||
}
|
||||
|
||||
.facts dd {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
|
||||
Reference in New Issue
Block a user