mansion: vivid services hall, compelling logged-out (0.41.0)

- replace barren login gate with a full services hall visible to everyone
- hero banner (emblem, value prop, cyan accent, login CTA)
- Showcase room: LIVE badge, feature chips, subdomain URL preview, and
  'log in to claim a subdomain' CTA when logged out; full register UI in
- Netdisk relay + Hosted webagent as designed SOON rooms (shimmer, feature chips)
- new mansion hall CSS; drop old thin mn-card/mn-soon styles
- verified live logged-out render at desktop width
This commit is contained in:
artheru 2026-06-10 06:24:51 +08:00
parent 4ad339a94f
commit 03b5f631b5
4 changed files with 141 additions and 54 deletions

View File

@ -4,7 +4,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<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="version" content="0.40.0-pwa" />
<meta name="version" content="0.41.0-mansion" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="icon" href="/app-192.png" type="image/png" sizes="192x192" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />

View File

@ -2,31 +2,76 @@ import React, { useCallback, useEffect, useState } from 'react';
import { api } from './api.js';
import { copyToast } from './toast.js';
// amerc Mansion a hub of amerc services. First service: Showcase (reverse proxy).
// 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>;
if (!auth.user) return (
<div className="ap ap-gate"><h2>The Mansion</h2><p>Log in to use amerc services.</p><button className="px-action" onClick={() => setRoute('signin')}>Go to Login</button></div>
);
const loggedIn = !!auth.user;
const handle = loggedIn ? auth.user.handle.toLowerCase().replace(/[^a-z0-9]/g, '') : 'you';
return (
<div className="ap">
<div className="ap-head"><h2>The Mansion</h2><span className="ap-by">{auth.user.handle}'s services</span></div>
<p className="ap-hint" style={{ marginBottom: 18 }}>amerc hosts your services behind its edge. Each one lives in a room of the mansion.</p>
<div className="mn-grid">
<Showcase handle={auth.user.handle.toLowerCase().replace(/[^a-z0-9]/g, '')} />
<div className="mn-soon"><div className="mn-soon-ico">🗄</div><strong>Netdisk relay</strong><span>coming soon</span></div>
<div className="mn-soon"><div className="mn-soon-ico">🤖</div><strong>Hosted webagent</strong><span>coming soon</span></div>
<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 Showcase({ handle }) {
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 load = useCallback(async () => { try { const d = await api('/showcase'); setList(d.showcases || []); } catch (e) { setErr(e.message); } }, []);
const load = useCallback(async () => {
if (!loggedIn) return;
try { const d = await api('/showcase'); setList(d.showcases || []); } catch (e) { setErr(e.message); }
}, [loggedIn]);
useEffect(() => { load(); }, [load]);
const register = async (e) => {
e.preventDefault(); setErr('');
@ -34,38 +79,50 @@ function Showcase({ handle }) {
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); } };
const copy = (t) => copyToast(t);
return (
<div className="mn-card mn-card-wide">
<div className="mn-card-head"><div className="mn-soon-ico">🌐</div><div><strong>Showcase</strong><span className="mn-tag">reverse proxy</span></div></div>
<p className="ap-hint">Publish a web service from a local port to a public subdomain. Register a name, then in kebab's <b>delivery center (交付中心)</b> map your local port requests to the public URL tunnel through your broker.</p>
<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={() => copy(created.url)}>{created.url} </code></label>
<label className="ap-field">broker WS (kebab connects here)<code className="ap-token" onClick={() => copy(created.edgeWs)}>{created.edgeWs} </code></label>
<label className="ap-field">edge token (bind {created.host})<code className="ap-token" onClick={() => copy(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>
<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>
{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>
</>
)}
<h4 className="ap-h4">My showcases</h4>
{list.map((s) => (
<div key={s.id} className="mn-row">
<a href={`http://${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>
);
}

View File

@ -7,7 +7,7 @@ import Account from './Account.jsx';
import TavernGame from './TavernGame.jsx';
import { api } from './api.js';
const RELEASE = '0.40.0-pwa';
const RELEASE = '0.41.0-mansion';
const NAV = [
{ id: 'home', label: 'Tavern' },

View File

@ -1056,16 +1056,46 @@ button {
}
.hero-hot:hover { background: rgba(80,210,255,0.2) !important; box-shadow: 0 0 0 3px rgba(120,225,255,0.85), 0 0 26px rgba(80,210,255,0.6) !important; }
/* mansion (services hub) */
.mn-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 16px; }
.mn-card { grid-column: 1 / -1; padding: 18px; border-radius: 14px; background: #130d20; border: 1px solid #2a1f3e; }
.mn-card-wide { max-width: 720px; }
.mn-card-head { display: flex; align-items: center; gap: 12px; margin-bottom: 8px; }
.mn-card-head strong { font-size: 17px; color: #fff; display: block; }
.mn-soon-ico { font-size: 26px; }
.mn-tag { font-size: 11px; color: #27d8ff; }
.mn-soon { padding: 18px; border-radius: 14px; background: #0e0a18; border: 1px dashed #2a1f3e; opacity: 0.6; display: flex; flex-direction: column; gap: 4px; align-items: flex-start; }
.mn-soon strong { color: #c9b8e6; } .mn-soon span { font-size: 12px; color: #7a6a98; }
/* mansion — services hall (0.41 vividness) */
.mn-hero { display: flex; gap: 18px; align-items: center; padding: 22px 24px; margin-bottom: 22px; border-radius: 16px;
background: radial-gradient(120% 160% at 0% 0%, #2a1a3e 0%, #160d22 60%), #160d22;
border: 1px solid #34254e; box-shadow: inset 0 1px 0 rgba(255,255,255,0.04), 0 10px 30px rgba(0,0,0,0.35); position: relative; overflow: hidden; }
.mn-hero::after { content: ''; position: absolute; right: -40px; top: -40px; width: 200px; height: 200px; border-radius: 50%;
background: radial-gradient(circle, rgba(39,216,255,0.16), transparent 70%); pointer-events: none; }
.mn-hero-emblem { font-size: 46px; filter: drop-shadow(0 4px 10px rgba(0,0,0,0.5)); line-height: 1; }
.mn-hero-body h2 { margin: 0 0 6px; color: #fff; font-size: 24px; }
.mn-hero-body p { margin: 0 0 12px; color: #b6a7d4; font-size: 13.5px; line-height: 1.65; max-width: 70ch; }
.mn-hero-you { display: inline-flex; align-items: center; gap: 8px; font-size: 13px; color: #8fe9c8; font-weight: 600; }
.mn-live-dot { width: 8px; height: 8px; border-radius: 50%; background: #36f0b0; box-shadow: 0 0 0 0 rgba(54,240,176,0.6); animation: mnpulse 2s infinite; }
@keyframes mnpulse { 0% { box-shadow: 0 0 0 0 rgba(54,240,176,0.5); } 70% { box-shadow: 0 0 0 7px rgba(54,240,176,0); } 100% { box-shadow: 0 0 0 0 rgba(54,240,176,0); } }
.mn-rooms { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 16px; align-items: start; }
.mn-room { padding: 18px; border-radius: 14px; background: linear-gradient(180deg, #160f24, #120c1d); border: 1px solid #2a1f3e;
transition: border-color 0.18s, transform 0.18s, box-shadow 0.18s; }
.mn-room.live { grid-column: 1 / -1; border-color: #2f5e6e; box-shadow: 0 0 0 1px rgba(39,216,255,0.08), 0 8px 24px rgba(0,0,0,0.3); }
.mn-room.live:hover { border-color: #46c8e0; box-shadow: 0 0 0 1px rgba(39,216,255,0.18), 0 10px 30px rgba(0,0,0,0.4); }
.mn-room.soon { opacity: 0.92; }
.mn-room.soon:hover { transform: translateY(-2px); border-color: #3a2c54; }
.mn-room-head { display: flex; align-items: center; gap: 12px; margin-bottom: 10px; }
.mn-room-ico { font-size: 28px; line-height: 1; filter: drop-shadow(0 2px 6px rgba(0,0,0,0.4)); }
.mn-room-titles { display: flex; flex-direction: column; }
.mn-room-titles strong { font-size: 17px; color: #fff; }
.mn-room-tag { font-size: 11px; color: #9a8ac0; letter-spacing: 0.4px; }
.mn-badge { margin-left: auto; font-size: 10px; font-weight: 700; letter-spacing: 0.8px; padding: 4px 9px; border-radius: 999px; }
.mn-badge.live { color: #06222f; background: linear-gradient(180deg, #5fe6ff, #27d8ff); box-shadow: 0 0 12px rgba(39,216,255,0.4); }
.mn-badge.soon { color: #c9b8e6; background: #241a36; border: 1px solid #3a2c54; }
.mn-room-desc { color: #b0a2cd; font-size: 13px; line-height: 1.6; margin: 0 0 12px; }
.mn-feats { list-style: none; margin: 0 0 12px; padding: 0; display: flex; flex-wrap: wrap; gap: 6px; }
.mn-feats li { font-size: 11.5px; color: #cfe6ff; background: rgba(39,216,255,0.08); border: 1px solid rgba(39,216,255,0.18); padding: 3px 9px; border-radius: 999px; }
.mn-room.soon .mn-feats li { color: #c4b6e2; background: rgba(150,120,200,0.08); border-color: rgba(150,120,200,0.18); }
.mn-room-foot { display: flex; align-items: center; gap: 10px; font-size: 11.5px; color: #8576a6; text-transform: uppercase; letter-spacing: 0.6px; }
.mn-soon-bar { flex: 0 0 80px; height: 4px; border-radius: 3px; background: #241a36; overflow: hidden; position: relative; }
.mn-soon-bar i { position: absolute; left: -40%; width: 40%; height: 100%; border-radius: 3px; background: linear-gradient(90deg, transparent, #6f5aa0, transparent); animation: mnslide 1.8s infinite; }
@keyframes mnslide { 0% { left: -40%; } 100% { left: 100%; } }
.mn-room-cta { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; margin-top: 4px; }
.mn-url-preview { font-family: ui-monospace, monospace; font-size: 13px; color: #6f7e98; background: #0a0f1a; border: 1px dashed #2e2440; padding: 8px 12px; border-radius: 8px; }
.mn-url-preview span { color: #27d8ff; }
.mn-mine { margin-top: 14px; }
.mn-reg { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin: 12px 0; }
.mn-reg input { padding: 9px 11px; border-radius: 8px; background: #0a0f1a; border: 1px solid #2e2440; color: #eaf2ff; font-family: inherit; width: 130px; }
.mn-prefix, .mn-suffix { color: #8a7aa8; font-size: 13px; }