amerc/src/Mansion.jsx
artheru 6b83b661fa mansion: a moonlit villa estate (2.3.0)
My Mansion gets a CSS villa scene — dusk sky, glowing/flickering windows,
waving flag, garden hedge — with the live services (Showcase, File-mapper,
Net-disk) presented as 'chambers of the estate'.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:30:06 +08:00

220 lines
11 KiB
JavaScript

import React, { useCallback, useEffect, useState } from 'react';
import { api } from './api.js';
import { copyToast } from './toast.js';
import Netdisk from './Netdisk.jsx';
// 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-villa">
<div className="mn-villa-scene" aria-hidden="true">
<span className="mn-villa-moon" />
<span className="mn-villa-star" style={{ left: '18%', top: '22%' }} />
<span className="mn-villa-star" style={{ left: '40%', top: '12%' }} />
<span className="mn-villa-star" style={{ left: '70%', top: '30%' }} />
<div className="mn-villa-flag"><i /></div>
<div className="mn-villa-roof" />
<div className="mn-villa-house">
<div className="mn-villa-windows">
{Array.from({ length: 6 }).map((_, i) => <span key={i} className={`mn-win${i === 4 ? ' dark' : ''}`} style={{ animationDelay: `${i * 0.4}s` }} />)}
</div>
<div className="mn-villa-door" />
</div>
<div className="mn-villa-hedge" />
</div>
<div className="mn-villa-body">
<span className="mn-villa-tag">🏰 THE ESTATE</span>
<h2>My Mansion</h2>
<p>Your estate on the open internet a villa of amerc services, each room 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 estate — the gates are open</span>
: <button className="px-action" onClick={() => setRoute('signin')}>Log in to open the gates →</button>}
</div>
</header>
<h3 className="mn-wing-title">⚜ Chambers of the estate</h3>
<div className="mn-rooms">
<ShowcaseRoom loggedIn={loggedIn} handle={handle} setRoute={setRoute} />
<FileMapperRoom loggedIn={loggedIn} setRoute={setRoute} />
<NetdiskRoom loggedIn={loggedIn} setRoute={setRoute} />
<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 Legion']}
/>
</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>
);
}
// File-mapper: agents publish named files; each download is fetched live from
// the broker and piped through — nothing is ever stored on amerc.
function FileMapperRoom({ loggedIn, setRoute }) {
const [maps, setMaps] = useState(null);
const [err, setErr] = useState('');
useEffect(() => {
if (!loggedIn) return;
api('/fm').then((d) => setMaps(d.maps || [])).catch((e) => { setErr(e.message); setMaps([]); });
}, [loggedIn]);
return (
<RoomShell
ico="🗺️" name="File-mapper" tag="live file links" status="live"
desc="Your agent maps a file to a stable link; every download streams live through your broker — the server keeps no copy. Perfect for big or fast-changing artifacts."
feats={['stable public URLs', 'zero server storage', 'served by your own broker']}
>
{!loggedIn ? (
<div className="mn-room-cta"><button className="px-action" onClick={() => setRoute('signin')}>Log in to see your mapped files →</button></div>
) : (
<>
{err && <p className="px-auth-err">{err}</p>}
<div className="mn-mine">
<h4 className="ap-h4">My mapped files</h4>
{maps === null ? <p className="ap-hint">loading…</p> : maps.length ? maps.map((f) => (
<div key={f.id} className="mn-row">
<span className={`ch-dot ${f.inst_status}`} title={`instance ${f.inst_status}`} />
<a href={f.url} target="_blank" rel="noreferrer">{f.name}</a>
<span className="mn-fm-meta">{f.class_name || `instance #${f.instance_id}`}</span>
<button className="px-action" onClick={() => copyToast(f.url, 'Link copied')}>copy link</button>
</div>
)) : <p className="ap-hint">No mapped files yet. Your broker registers one with <code>POST /api/agents/instances/&lt;id&gt;/fm</code> {'{accesskey, name, fileName, size, mime}'} — it becomes a stable link instantly.</p>}
</div>
</>
)}
</RoomShell>
);
}
// Net-disk: real storage on amerc, 100 MB per account.
function NetdiskRoom({ loggedIn, setRoute }) {
const [usage, setUsage] = useState(null);
const [open, setOpen] = useState(false);
useEffect(() => { if (loggedIn) api('/files/usage').then(setUsage).catch(() => {}); }, [loggedIn, open]);
const pct = usage ? Math.min(100, Math.round((usage.used / usage.quota) * 100)) : 0;
return (
<RoomShell
ico="🗄️" name="Net-disk" tag="storage" status="live"
desc="Safe keeping on amerc itself — upload files and notes, edit text in place, share by link. Each account gets 100 MB."
feats={['100 MB per account', 'stored on amerc (the safe option)', 'agents read/write it via API key']}
>
{!loggedIn ? (
<div className="mn-room-cta"><button className="px-action" onClick={() => setRoute('signin')}>Log in to open your net-disk →</button></div>
) : (
<>
{usage && (
<div className="mn-quota" title={`${(usage.used / 1048576).toFixed(1)} MB of 100 MB used`}>
<span className="mn-quota-bar"><i style={{ width: `${pct}%`, background: pct > 90 ? '#ff7a8c' : undefined }} /></span>
<span className="mn-quota-label">{(usage.used / 1048576).toFixed(1)} / 100 MB</span>
</div>
)}
<button className="px-action" onClick={() => setOpen((v) => !v)}>{open ? 'Close net-disk' : 'Open net-disk'}</button>
{open && <div className="mn-nd"><Netdisk /></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>
);
}