toast notifications (0.32.0): elegant feedback for copies/actions

- src/toast.js: dependency-free toast + copyToast helpers
- wired copyToast into all token/accesskey/url/key copy actions (silent before)
- replaces jarring browser dialogs for copy feedback; consistent polished UX
This commit is contained in:
artheru 2026-06-10 05:22:16 +08:00
parent 0de81620b0
commit 3090ed92b0
7 changed files with 33 additions and 9 deletions

View File

@ -7,7 +7,7 @@
name="description" name="description"
content="amerc is an agent mercenary layer for embedding, bringing, and hosting agents across vertical software boundaries." content="amerc is an agent mercenary layer for embedding, bringing, and hosting agents across vertical software boundaries."
/> />
<meta name="version" content="0.31.0-activity" /> <meta name="version" content="0.32.0-toasts" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" /> <link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />

View File

@ -1,6 +1,6 @@
{ {
"name": "amerc-site", "name": "amerc-site",
"version": "0.31.0-activity", "version": "0.32.0-toasts",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@ -1,5 +1,6 @@
import React, { useCallback, useEffect, useState } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import { api } from './api.js'; import { api } from './api.js';
import { copyToast } from './toast.js';
const STATUS_COLOR = { online: '#36f0b0', pending: '#f2b85f', lost: '#ff9f5a', down: '#ff7a8c' }; const STATUS_COLOR = { online: '#36f0b0', pending: '#f2b85f', lost: '#ff9f5a', down: '#ff7a8c' };
const TAG_EMOJI = { backend: '🛠️', model: '🧠', frontend: '🎨', ui: '🖌️', web: '🌐', data: '📊', devops: '⚙️', ml: '🤖', security: '🔐', api: '🔌', bot: '🤖', chat: '💬', test: '🧪', docs: '📄', design: '✏️', ops: '🚦', research: '🔬' }; const TAG_EMOJI = { backend: '🛠️', model: '🧠', frontend: '🎨', ui: '🖌️', web: '🌐', data: '📊', devops: '⚙️', ml: '🤖', security: '🔐', api: '🔌', bot: '🤖', chat: '💬', test: '🧪', docs: '📄', design: '✏️', ops: '🚦', research: '🔬' };
@ -92,7 +93,7 @@ function ClassModal({ id, auth, onClose }) {
{err && <p className="px-auth-err">{err}</p>} {err && <p className="px-auth-err">{err}</p>}
<div className="ap-sub"> <div className="ap-sub">
{auth.user ? <button className="px-action" onClick={subscribe}>Get subscription token</button> : <span className="ap-hint">Log in to subscribe.</span>} {auth.user ? <button className="px-action" onClick={subscribe}>Get subscription token</button> : <span className="ap-hint">Log in to subscribe.</span>}
{sub && <code className="ap-token" onClick={() => navigator.clipboard?.writeText(sub)}>{sub} </code>} {sub && <code className="ap-token" onClick={() => copyToast(sub)}>{sub} </code>}
</div> </div>
<h4 className="ap-h4">Instances</h4> <h4 className="ap-h4">Instances</h4>
<div className="ap-inst-list"> <div className="ap-inst-list">
@ -178,7 +179,7 @@ function KeysCard() {
<input placeholder="key name (e.g. my-bot)" value={name} onChange={(e) => setName(e.target.value)} required /> <input placeholder="key name (e.g. my-bot)" value={name} onChange={(e) => setName(e.target.value)} required />
<button className="px-action" type="submit">+ Mint key</button> <button className="px-action" type="submit">+ Mint key</button>
</form> </form>
{created && <div className="mn-created"><p> minted copy now, shown once:</p><code className="ap-token" onClick={() => navigator.clipboard?.writeText(created.key)}>{created.key} </code></div>} {created && <div className="mn-created"><p> minted copy now, shown once:</p><code className="ap-token" onClick={() => copyToast(created.key)}>{created.key} </code></div>}
{err && <p className="px-auth-err">{err}</p>} {err && <p className="px-auth-err">{err}</p>}
{keys.map((k) => ( {keys.map((k) => (
<div key={k.id} className="ap-mine-inst"> <div key={k.id} className="ap-mine-inst">
@ -257,7 +258,7 @@ export function BoothDashboard({ auth, setRoute }) {
{insts.map((i) => ( {insts.map((i) => (
<div key={i.id} className="ap-mine-inst"> <div key={i.id} className="ap-mine-inst">
<span className="ap-inst-status" style={{ background: STATUS_COLOR[i.status] }} /> #{i.id} <em>{i.status}</em> <span className="ap-inst-status" style={{ background: STATUS_COLOR[i.status] }} /> #{i.id} <em>{i.status}</em>
{i.accesskey && <code className="ap-token" title="accesskey — your broker connects with this" onClick={() => navigator.clipboard?.writeText(i.accesskey)}>{i.accesskey.slice(0, 14)} </code>} {i.accesskey && <code className="ap-token" title="accesskey — your broker connects with this" onClick={() => copyToast(i.accesskey)}>{i.accesskey.slice(0, 14)} </code>}
<button className="px-action" onClick={() => shutdown(i)}>shut down</button> <button className="px-action" onClick={() => shutdown(i)}>shut down</button>
</div> </div>
))} ))}
@ -270,8 +271,8 @@ export function BoothDashboard({ auth, setRoute }) {
<div className="ap-modal-box" onClick={(e) => e.stopPropagation()}> <div className="ap-modal-box" onClick={(e) => e.stopPropagation()}>
<div className="ap-modal-head"><h3>Instance #{newInst.id} created</h3><button onClick={() => setNewInst(null)}></button></div> <div className="ap-modal-head"><h3>Instance #{newInst.id} created</h3><button onClick={() => setNewInst(null)}></button></div>
<p className="ap-hint">Run your broker against this instance, then it shows online. The accesskey is the relay token.</p> <p className="ap-hint">Run your broker against this instance, then it shows online. The accesskey is the relay token.</p>
<label className="ap-field">accesskey<code className="ap-token" onClick={() => navigator.clipboard?.writeText(newInst.accesskey)}>{newInst.accesskey} </code></label> <label className="ap-field">accesskey<code className="ap-token" onClick={() => copyToast(newInst.accesskey)}>{newInst.accesskey} </code></label>
<label className="ap-field">broker WS<code className="ap-token" onClick={() => navigator.clipboard?.writeText(newInst.relayWs)}>{newInst.relayWs} </code></label> <label className="ap-field">broker WS<code className="ap-token" onClick={() => copyToast(newInst.relayWs)}>{newInst.relayWs} </code></label>
<label className="ap-field">heartbeat<code className="ap-token">POST /api/agents/instances/{newInst.id}/heartbeat {'{'}accesskey,broker,tui{'}'}</code></label> <label className="ap-field">heartbeat<code className="ap-token">POST /api/agents/instances/{newInst.id}/heartbeat {'{'}accesskey,broker,tui{'}'}</code></label>
</div> </div>
</div> </div>

View File

@ -1,5 +1,6 @@
import React, { useCallback, useEffect, useState } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import { api } from './api.js'; 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 hub of amerc services. First service: Showcase (reverse proxy).
export default function Mansion({ auth, setRoute }) { export default function Mansion({ auth, setRoute }) {
@ -33,7 +34,7 @@ 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) => navigator.clipboard?.writeText(t); const copy = (t) => copyToast(t);
return ( return (
<div className="mn-card mn-card-wide"> <div className="mn-card mn-card-wide">

View File

@ -6,7 +6,7 @@ import Mansion from './Mansion.jsx';
import TavernGame from './TavernGame.jsx'; import TavernGame from './TavernGame.jsx';
import { api } from './api.js'; import { api } from './api.js';
const RELEASE = '0.31.0-activity'; const RELEASE = '0.32.0-toasts';
const NAV = [ const NAV = [
{ id: 'home', label: 'Tavern' }, { id: 'home', label: 'Tavern' },

View File

@ -1146,3 +1146,9 @@ button {
.act-ico { font-size: 16px; flex: none; } .act-ico { font-size: 16px; flex: none; }
.act-text { font-size: 13px; color: #c9b8e6; flex: 1; } .act-text { font-size: 13px; color: #c9b8e6; flex: 1; }
.act-time { font-size: 11px; color: #6f5b8c; flex: none; } .act-time { font-size: 11px; color: #6f5b8c; flex: none; }
/* toasts (0.32) */
#amerc-toasts { position: fixed; bottom: 20px; right: 20px; z-index: 99999; display: flex; flex-direction: column; gap: 8px; pointer-events: none; }
.amerc-toast { background: #142238; color: #eaf2ff; border: 1px solid #2e4a6e; border-left: 3px solid #36f0b0; border-radius: 9px; padding: 11px 16px; font-family: Inter, sans-serif; font-size: 13px; box-shadow: 0 10px 28px rgba(0,0,0,0.55); opacity: 0; transform: translateX(24px); transition: opacity .25s ease, transform .25s ease; max-width: 320px; }
.amerc-toast.in { opacity: 1; transform: none; }
.amerc-toast.t-err { border-left-color: #ff7a8c; }

16
src/toast.js Normal file
View File

@ -0,0 +1,16 @@
// Tiny dependency-free toast notifications, callable from anywhere.
export function toast(msg, type = 'ok', ms = 2600) {
let c = document.getElementById('amerc-toasts');
if (!c) { c = document.createElement('div'); c.id = 'amerc-toasts'; document.body.appendChild(c); }
const t = document.createElement('div');
t.className = `amerc-toast t-${type}`;
t.textContent = msg;
c.appendChild(t);
requestAnimationFrame(() => t.classList.add('in'));
setTimeout(() => { t.classList.remove('in'); setTimeout(() => t.remove(), 300); }, ms);
}
export function copyToast(text, label = 'Copied') {
try { navigator.clipboard.writeText(text).then(() => toast(`${label}`)).catch(() => toast('Copy failed', 'err')); }
catch { toast('Copy failed', 'err'); }
}