amerc/src/Mansion.jsx
artheru fb3c523d4a booth + mansion: fix loading-state flashes (0.57.0)
- BoothDashboard had no loaded flag, so returning publishers briefly saw the
  'Welcome to your booth' new-user onboarding before their classes loaded
- add loaded-gating + a shimmer skeleton; welcome shows only after load
- same fix for the Mansion 'My showcases' list ('No showcases yet' flash)
- completes the loading-skeleton pattern across all data surfaces
  (Browse, Booth, Mansion)
- verified live (throttled): skeleton during load, no welcome flash,
  welcome/content only after loaded
2026-06-10 08:10:50 +08:00

139 lines
6.9 KiB
JavaScript

import React, { useCallback, useEffect, useState } from 'react';
import { api } from './api.js';
import { copyToast } from './toast.js';
// amerc Mansion — a hall of amerc services. First live service: Showcase (reverse proxy).
export default function Mansion({ auth, setRoute }) {
if (!auth.ready) return <div className="ap"><p>Loading</p></div>;
const loggedIn = !!auth.user;
const handle = loggedIn ? auth.user.handle.toLowerCase().replace(/[^a-z0-9]/g, '') : 'you';
return (
<div className="ap mn">
<header className="mn-hero">
<div className="mn-hero-emblem" aria-hidden="true">🏰</div>
<div className="mn-hero-body">
<h2>The Mansion</h2>
<p>A hall of amerc services that put your work on the open internet each lives behind amerc's edge and is brought to life by your own broker. No servers to rent, no ports to forward.</p>
{loggedIn
? <span className="mn-hero-you"><i className="mn-live-dot" /> {auth.user.handle}'s rooms are open</span>
: <button className="px-action" onClick={() => setRoute('signin')}>Log in to open a room </button>}
</div>
</header>
<div className="mn-rooms">
<ShowcaseRoom loggedIn={loggedIn} handle={handle} setRoute={setRoute} />
<SoonRoom
ico="🗄️" name="Netdisk relay" tag="storage"
desc="Mount your amerc netdisk and stream files straight through the edge — share large artifacts by link without uploading them twice."
feats={['link-shareable files', 'resumable streaming', 'agent-readable via API key']}
/>
<SoonRoom
ico="🤖" name="Hosted webagent" tag="runtime"
desc="Let amerc host the broker for you — keep an agent class always-on without leaving your own machine running."
feats={['always-on instances', 'managed runtime', 'one-click from My Booth']}
/>
</div>
</div>
);
}
function RoomShell({ ico, name, tag, status, desc, feats, children }) {
return (
<section className={`mn-room${status === 'live' ? ' live' : ' soon'}`}>
<div className="mn-room-head">
<span className="mn-room-ico" aria-hidden="true">{ico}</span>
<div className="mn-room-titles">
<strong>{name}</strong>
<span className="mn-room-tag">{tag}</span>
</div>
<span className={`mn-badge ${status}`}>{status === 'live' ? '● LIVE' : 'SOON'}</span>
</div>
<p className="mn-room-desc">{desc}</p>
{feats && <ul className="mn-feats">{feats.map((f) => <li key={f}>{f}</li>)}</ul>}
{children}
</section>
);
}
function SoonRoom(props) {
return (
<RoomShell {...props} status="soon">
<div className="mn-room-foot"><span className="mn-soon-bar"><i /></span>in development</div>
</RoomShell>
);
}
function ShowcaseRoom({ loggedIn, handle, setRoute }) {
const [list, setList] = useState([]);
const [name, setName] = useState('');
const [created, setCreated] = useState(null);
const [err, setErr] = useState('');
const [loaded, setLoaded] = useState(false);
const load = useCallback(async () => {
if (!loggedIn) return;
try { const d = await api('/showcase'); setList(d.showcases || []); } catch (e) { setErr(e.message); } finally { setLoaded(true); }
}, [loggedIn]);
useEffect(() => { load(); }, [load]);
const register = async (e) => {
e.preventDefault(); setErr('');
try { const r = await api('/showcase', { method: 'POST', body: { name } }); setCreated(r); setName(''); load(); }
catch (e2) { setErr(e2.message); }
};
const del = async (id) => { if (!window.confirm('Delete this showcase?')) return; try { await api(`/showcase/${id}`, { method: 'DELETE' }); load(); } catch (e2) { setErr(e2.message); } };
return (
<RoomShell
ico="🌐" name="Showcase" tag="reverse proxy" status="live"
desc="Publish a web service from a local port to a public subdomain. Register a name, then map your local port in kebab's delivery center (交付中心) — requests to the public URL tunnel through your broker, TLS included."
feats={['your own *.amerc.ai subdomain', 'automatic HTTPS', 'HTTP + WebSocket tunneling']}
>
{!loggedIn ? (
<div className="mn-room-cta">
<div className="mn-url-preview"><span>show1</span>.{handle}.amerc.ai</div>
<button className="px-action" onClick={() => setRoute('signin')}>Log in to claim a subdomain </button>
</div>
) : (
<>
<form className="mn-reg" onSubmit={register}>
<span className="mn-prefix">register</span>
<input placeholder="show1" value={name} onChange={(e) => setName(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ''))} required />
<span className="mn-suffix">.{handle}.amerc.ai</span>
<button className="px-action px-auth-submit" type="submit">Register</button>
</form>
{err && <p className="px-auth-err">{err}</p>}
{created && (
<div className="mn-created">
<p> <b>{created.host}</b> reserved · DNS {created.dns}</p>
{created.tls && <p className="ap-hint">🔒 {created.tls}</p>}
<label className="ap-field">public URL<code className="ap-token" onClick={() => copyToast(created.url)}>{created.url} </code></label>
<label className="ap-field">broker WS (kebab connects here)<code className="ap-token" onClick={() => copyToast(created.edgeWs)}>{created.edgeWs} </code></label>
<label className="ap-field">edge token (bind {created.host})<code className="ap-token" onClick={() => copyToast(created.edgeToken)}>{created.edgeToken.slice(0, 28)} </code></label>
<p className="ap-hint">In the kebab delivery center: connect a broker to the WS with this token, send <code>{`{type:"bind",host:"${created.host}",port:<localPort>}`}</code>, and your local service is live at the public URL.</p>
</div>
)}
<div className="mn-mine">
<h4 className="ap-h4">My showcases</h4>
{!loaded ? (
<div className="mn-skel" aria-hidden="true">
<div className="ap-skel-row"><span className="sk-p" style={{ flex: 1 }} /></div>
<div className="ap-skel-row"><span className="sk-p" style={{ flex: 1 }} /></div>
</div>
) : (
<>
{list.map((s) => (
<div key={s.id} className="mn-row">
<a href={`https://${s.host}/`} target="_blank" rel="noreferrer">{s.host}</a>
<span>registered {new Date(s.created_at).toLocaleDateString()}</span>
<button className="px-action" onClick={() => del(s.id)}>delete</button>
</div>
))}
{!list.length && <p className="ap-hint">No showcases yet register one above.</p>}
</>
)}
</div>
</>
)}
</RoomShell>
);
}