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:
parent
4ad339a94f
commit
03b5f631b5
@ -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.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="/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" />
|
||||||
|
|||||||
141
src/Mansion.jsx
141
src/Mansion.jsx
@ -2,31 +2,76 @@ import React, { useCallback, useEffect, useState } from 'react';
|
|||||||
import { api } from './api.js';
|
import { api } from './api.js';
|
||||||
import { copyToast } from './toast.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 }) {
|
export default function Mansion({ auth, setRoute }) {
|
||||||
if (!auth.ready) return <div className="ap"><p>Loading…</p></div>;
|
if (!auth.ready) return <div className="ap"><p>Loading…</p></div>;
|
||||||
if (!auth.user) return (
|
const loggedIn = !!auth.user;
|
||||||
<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 handle = loggedIn ? auth.user.handle.toLowerCase().replace(/[^a-z0-9]/g, '') : 'you';
|
||||||
);
|
|
||||||
return (
|
return (
|
||||||
<div className="ap">
|
<div className="ap mn">
|
||||||
<div className="ap-head"><h2>The Mansion</h2><span className="ap-by">{auth.user.handle}'s services</span></div>
|
<header className="mn-hero">
|
||||||
<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-hero-emblem" aria-hidden="true">🏰</div>
|
||||||
<div className="mn-grid">
|
<div className="mn-hero-body">
|
||||||
<Showcase handle={auth.user.handle.toLowerCase().replace(/[^a-z0-9]/g, '')} />
|
<h2>The Mansion</h2>
|
||||||
<div className="mn-soon"><div className="mn-soon-ico">🗄️</div><strong>Netdisk relay</strong><span>coming soon</span></div>
|
<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>
|
||||||
<div className="mn-soon"><div className="mn-soon-ico">🤖</div><strong>Hosted webagent</strong><span>coming soon</span></div>
|
{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>
|
||||||
</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 [list, setList] = useState([]);
|
||||||
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 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]);
|
useEffect(() => { load(); }, [load]);
|
||||||
const register = async (e) => {
|
const register = async (e) => {
|
||||||
e.preventDefault(); setErr('');
|
e.preventDefault(); setErr('');
|
||||||
@ -34,38 +79,50 @@ function Showcase({ handle }) {
|
|||||||
catch (e2) { setErr(e2.message); }
|
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 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 (
|
return (
|
||||||
<div className="mn-card mn-card-wide">
|
<RoomShell
|
||||||
<div className="mn-card-head"><div className="mn-soon-ico">🌐</div><div><strong>Showcase</strong><span className="mn-tag">reverse proxy</span></div></div>
|
ico="🌐" name="Showcase" tag="reverse proxy" status="live"
|
||||||
<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>
|
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."
|
||||||
<form className="mn-reg" onSubmit={register}>
|
feats={['your own *.amerc.ai subdomain', 'automatic HTTPS', 'HTTP + WebSocket tunneling']}
|
||||||
<span className="mn-prefix">register</span>
|
>
|
||||||
<input placeholder="show1" value={name} onChange={(e) => setName(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ''))} required />
|
{!loggedIn ? (
|
||||||
<span className="mn-suffix">.{handle}.amerc.ai</span>
|
<div className="mn-room-cta">
|
||||||
<button className="px-action px-auth-submit" type="submit">Register</button>
|
<div className="mn-url-preview"><span>show1</span>.{handle}.amerc.ai</div>
|
||||||
</form>
|
<button className="px-action" onClick={() => setRoute('signin')}>Log in to claim a subdomain →</button>
|
||||||
{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>
|
|
||||||
</div>
|
</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>
|
</RoomShell>
|
||||||
{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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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.40.0-pwa';
|
const RELEASE = '0.41.0-mansion';
|
||||||
|
|
||||||
const NAV = [
|
const NAV = [
|
||||||
{ id: 'home', label: 'Tavern' },
|
{ id: 'home', label: 'Tavern' },
|
||||||
|
|||||||
@ -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; }
|
.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) */
|
/* mansion — services hall (0.41 vividness) */
|
||||||
.mn-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 16px; }
|
.mn-hero { display: flex; gap: 18px; align-items: center; padding: 22px 24px; margin-bottom: 22px; border-radius: 16px;
|
||||||
.mn-card { grid-column: 1 / -1; padding: 18px; border-radius: 14px; background: #130d20; border: 1px solid #2a1f3e; }
|
background: radial-gradient(120% 160% at 0% 0%, #2a1a3e 0%, #160d22 60%), #160d22;
|
||||||
.mn-card-wide { max-width: 720px; }
|
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-card-head { display: flex; align-items: center; gap: 12px; margin-bottom: 8px; }
|
.mn-hero::after { content: ''; position: absolute; right: -40px; top: -40px; width: 200px; height: 200px; border-radius: 50%;
|
||||||
.mn-card-head strong { font-size: 17px; color: #fff; display: block; }
|
background: radial-gradient(circle, rgba(39,216,255,0.16), transparent 70%); pointer-events: none; }
|
||||||
.mn-soon-ico { font-size: 26px; }
|
.mn-hero-emblem { font-size: 46px; filter: drop-shadow(0 4px 10px rgba(0,0,0,0.5)); line-height: 1; }
|
||||||
.mn-tag { font-size: 11px; color: #27d8ff; }
|
.mn-hero-body h2 { margin: 0 0 6px; color: #fff; font-size: 24px; }
|
||||||
.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-hero-body p { margin: 0 0 12px; color: #b6a7d4; font-size: 13.5px; line-height: 1.65; max-width: 70ch; }
|
||||||
.mn-soon strong { color: #c9b8e6; } .mn-soon span { font-size: 12px; color: #7a6a98; }
|
.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 { 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-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; }
|
.mn-prefix, .mn-suffix { color: #8a7aa8; font-size: 13px; }
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user