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
This commit is contained in:
artheru 2026-06-10 08:10:50 +08:00
parent 04128a24d3
commit fb3c523d4a
6 changed files with 38 additions and 13 deletions

View File

@ -4,7 +4,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#0e0a14" /> <meta name="theme-color" content="#0e0a14" />
<meta name="description" content="amerc is the agent mercenary tavern — hire, run, publish, and remotely deliver AI agents across software boundaries. No infrastructure, just skills." /> <meta name="description" content="amerc is the agent mercenary tavern — hire, run, publish, and remotely deliver AI agents across software boundaries. No infrastructure, just skills." />
<meta name="version" content="0.56.0-routes" /> <meta name="version" content="0.57.0-loadstates" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" /> <link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="icon" href="/app-192.png" type="image/png" sizes="192x192" /> <link rel="icon" href="/app-192.png" type="image/png" sizes="192x192" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" /> <link rel="apple-touch-icon" href="/apple-touch-icon.png" />

View File

@ -1,6 +1,6 @@
{ {
"name": "amerc-site", "name": "amerc-site",
"version": "0.56.0-routes", "version": "0.57.0-loadstates",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@ -223,6 +223,7 @@ export function BoothDashboard({ auth, setRoute }) {
const [err, setErr] = useState(''); const [err, setErr] = useState('');
const [form, setForm] = useState({ name: '', tags: '', description: '', kind: 'codex', command: 'codex --yolo', visibility: 'public' }); const [form, setForm] = useState({ name: '', tags: '', description: '', kind: 'codex', command: 'codex --yolo', visibility: 'public' });
const [newInst, setNewInst] = useState(null); const [newInst, setNewInst] = useState(null);
const [loaded, setLoaded] = useState(false);
const load = useCallback(async () => { const load = useCallback(async () => {
setErr(''); setErr('');
@ -231,7 +232,7 @@ export function BoothDashboard({ auth, setRoute }) {
setClasses(c.classes || []); setClasses(c.classes || []);
const all = await Promise.all((c.classes || []).map((x) => api(`/agents/instances?classId=${x.id}`).then((r) => r.instances).catch(() => []))); const all = await Promise.all((c.classes || []).map((x) => api(`/agents/instances?classId=${x.id}`).then((r) => r.instances).catch(() => [])));
setInsts(all.flat()); setInsts(all.flat());
} catch (e) { setErr(e.message); } } catch (e) { setErr(e.message); } finally { setLoaded(true); }
}, []); }, []);
useEffect(() => { if (auth.user) load(); }, [auth.user, load]); useEffect(() => { if (auth.user) load(); }, [auth.user, load]);
@ -269,7 +270,15 @@ export function BoothDashboard({ auth, setRoute }) {
<button className="px-action px-auth-submit" type="submit">Publish class</button> <button className="px-action px-auth-submit" type="submit">Publish class</button>
</form> </form>
<div className="ap-mine"> <div className="ap-mine">
{!classes.length ? ( {!loaded ? (
<div className="ap-mine-skel" aria-hidden="true">
<span className="sk-p" style={{ width: '40%', height: 12 }} />
<div className="ap-skel-row"><span className="sk-av" /><span className="sk-p" style={{ flex: 1 }} /></div>
<div className="ap-skel-row"><span className="sk-av" /><span className="sk-p" style={{ flex: 1 }} /></div>
<span className="sk-p" style={{ width: '30%', height: 12, marginTop: 14 }} />
<div className="ap-skel-row"><span className="sk-p" style={{ flex: 1 }} /></div>
</div>
) : !classes.length ? (
<div className="ap-booth-welcome"> <div className="ap-booth-welcome">
<span className="ap-booth-welcome-ico" aria-hidden="true">🗡</span> <span className="ap-booth-welcome-ico" aria-hidden="true">🗡</span>
<h3>Welcome to your booth, {auth.user.handle}.</h3> <h3>Welcome to your booth, {auth.user.handle}.</h3>

View File

@ -68,9 +68,10 @@ function ShowcaseRoom({ loggedIn, handle, setRoute }) {
const [name, setName] = useState(''); const [name, setName] = useState('');
const [created, setCreated] = useState(null); const [created, setCreated] = useState(null);
const [err, setErr] = useState(''); const [err, setErr] = useState('');
const [loaded, setLoaded] = useState(false);
const load = useCallback(async () => { const load = useCallback(async () => {
if (!loggedIn) return; if (!loggedIn) return;
try { const d = await api('/showcase'); setList(d.showcases || []); } catch (e) { setErr(e.message); } try { const d = await api('/showcase'); setList(d.showcases || []); } catch (e) { setErr(e.message); } finally { setLoaded(true); }
}, [loggedIn]); }, [loggedIn]);
useEffect(() => { load(); }, [load]); useEffect(() => { load(); }, [load]);
const register = async (e) => { const register = async (e) => {
@ -112,14 +113,23 @@ function ShowcaseRoom({ loggedIn, handle, setRoute }) {
)} )}
<div className="mn-mine"> <div className="mn-mine">
<h4 className="ap-h4">My showcases</h4> <h4 className="ap-h4">My showcases</h4>
{list.map((s) => ( {!loaded ? (
<div key={s.id} className="mn-row"> <div className="mn-skel" aria-hidden="true">
<a href={`https://${s.host}/`} target="_blank" rel="noreferrer">{s.host}</a> <div className="ap-skel-row"><span className="sk-p" style={{ flex: 1 }} /></div>
<span>registered {new Date(s.created_at).toLocaleDateString()}</span> <div className="ap-skel-row"><span className="sk-p" style={{ flex: 1 }} /></div>
<button className="px-action" onClick={() => del(s.id)}>delete</button>
</div> </div>
))} ) : (
{!list.length && <p className="ap-hint">No showcases yet register one above.</p>} <>
{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> </div>
</> </>
)} )}

View File

@ -7,7 +7,7 @@ import Account from './Account.jsx';
import TavernGame from './TavernGame.jsx'; import TavernGame from './TavernGame.jsx';
import { api } from './api.js'; import { api } from './api.js';
const RELEASE = '0.56.0-routes'; const RELEASE = '0.57.0-loadstates';
const NAV = [ const NAV = [
{ id: 'home', label: 'Tavern' }, { id: 'home', label: 'Tavern' },

View File

@ -1407,3 +1407,9 @@ button {
.route-enter { flex: 1; min-height: 0; display: flex; flex-direction: column; animation: route-in .34s cubic-bezier(.2,.7,.2,1); } .route-enter { flex: 1; min-height: 0; display: flex; flex-direction: column; animation: route-in .34s cubic-bezier(.2,.7,.2,1); }
@keyframes route-in { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } } @keyframes route-in { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
@media (prefers-reduced-motion: reduce) { .route-enter { animation: none; } } @media (prefers-reduced-motion: reduce) { .route-enter { animation: none; } }
/* booth + mansion loading skeletons (0.57) */
.ap-mine-skel { display: flex; flex-direction: column; gap: 12px; }
.ap-skel-row { display: flex; align-items: center; gap: 11px; }
.mn-skel { display: flex; flex-direction: column; gap: 10px; margin-top: 8px; }
.mn-skel .ap-skel-row .sk-p { height: 12px; }