- 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
139 lines
6.9 KiB
JavaScript
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>
|
|
);
|
|
}
|