t3-code-android-nightly/.repos/alchemy-effect/examples/cloudflare-worker/assets/index.html
Julius Marminge bd851c0203
chore: add Alchemy reference repo subtree (#2918)
Co-authored-by: codex <codex@users.noreply.github.com>
2026-06-03 00:08:31 +00:00

1023 lines
30 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Room Chat</title>
<style>
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family:
system-ui,
-apple-system,
sans-serif;
background: #0f172a;
color: #e2e8f0;
height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 1rem;
}
.container {
width: 100%;
max-width: 560px;
display: flex;
flex-direction: column;
gap: 0.75rem;
height: 90vh;
}
h1 {
font-size: 1.25rem;
font-weight: 600;
color: #94a3b8;
}
#status {
font-size: 0.8rem;
padding: 0.35rem 0.75rem;
border-radius: 999px;
display: inline-block;
align-self: flex-start;
}
#status.connected {
background: #065f46;
color: #6ee7b7;
}
#status.disconnected {
background: #7f1d1d;
color: #fca5a5;
}
#status.connecting {
background: #78350f;
color: #fcd34d;
}
#messages {
flex: 1;
overflow-y: auto;
background: #1e293b;
border-radius: 0.5rem;
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.35rem;
font-size: 0.9rem;
font-family: "SF Mono", "Fira Code", monospace;
}
.msg {
padding: 0.25rem 0;
border-bottom: 1px solid #334155;
word-break: break-word;
}
.msg.system {
color: #64748b;
font-style: italic;
}
#form {
display: flex;
gap: 0.5rem;
}
#input {
flex: 1;
padding: 0.6rem 0.75rem;
border-radius: 0.375rem;
border: 1px solid #334155;
background: #1e293b;
color: #e2e8f0;
font-size: 0.9rem;
outline: none;
}
#input:focus {
border-color: #3b82f6;
}
button {
padding: 0.6rem 1.25rem;
border-radius: 0.375rem;
border: none;
background: #3b82f6;
color: #fff;
font-weight: 600;
cursor: pointer;
font-size: 0.9rem;
}
button:hover {
background: #2563eb;
}
button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
}
.header-right {
display: flex;
align-items: center;
gap: 0.5rem;
}
#avatar-btn {
width: 2.25rem;
height: 2.25rem;
border-radius: 50%;
background: #334155;
border: 2px solid #475569;
color: #94a3b8;
font-size: 1rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
transition:
border-color 0.15s,
background 0.15s;
position: relative;
}
#avatar-btn:hover {
border-color: #3b82f6;
background: #1e293b;
}
#avatar-btn.active {
border-color: #3b82f6;
background: #1e293b;
}
#avatar-btn svg {
width: 1.1rem;
height: 1.1rem;
fill: currentColor;
}
#toggle {
background: #dc2626;
font-size: 0.8rem;
padding: 0.35rem 0.75rem;
}
#toggle:hover {
background: #b91c1c;
}
#toggle.off {
background: #16a34a;
}
#toggle.off:hover {
background: #15803d;
}
#actions {
display: flex;
gap: 0.5rem;
}
#increment {
background: #7c3aed;
}
#increment:hover {
background: #6d28d9;
}
#increment:disabled {
opacity: 0.4;
cursor: not-allowed;
}
#workflow-run {
background: #0ea5e9;
}
#workflow-run:hover {
background: #0284c7;
}
#workflow-run:disabled {
opacity: 0.4;
cursor: not-allowed;
}
#workflow-panel {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.75rem;
background: #1e293b;
border-radius: 0.5rem;
border: 1px solid #334155;
}
#workflow-panel h2 {
font-size: 0.75rem;
font-weight: 600;
color: #94a3b8;
text-transform: uppercase;
letter-spacing: 0.04em;
}
#workflow-status {
font-size: 0.75rem;
font-family: "SF Mono", "Fira Code", monospace;
color: #cbd5e1;
white-space: pre-wrap;
word-break: break-word;
min-height: 2.5rem;
line-height: 1.4;
}
#artifacts-panel {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.75rem;
background: #1e293b;
border-radius: 0.5rem;
border: 1px solid #334155;
}
#artifacts-panel h2 {
font-size: 0.75rem;
font-weight: 600;
color: #94a3b8;
text-transform: uppercase;
letter-spacing: 0.04em;
}
#artifacts-status {
font-size: 0.75rem;
font-family: "SF Mono", "Fira Code", monospace;
color: #cbd5e1;
white-space: pre-wrap;
word-break: break-word;
min-height: 2.5rem;
line-height: 1.4;
}
#ai-panel {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.75rem;
background: #1e293b;
border-radius: 0.5rem;
border: 1px solid #334155;
}
#ai-panel h2 {
font-size: 0.75rem;
font-weight: 600;
color: #94a3b8;
text-transform: uppercase;
letter-spacing: 0.04em;
}
#ai-status {
font-size: 0.75rem;
font-family: "SF Mono", "Fira Code", monospace;
color: #cbd5e1;
white-space: pre-wrap;
word-break: break-word;
min-height: 2.5rem;
line-height: 1.4;
}
#auth-panel {
display: none;
flex-direction: column;
gap: 0.75rem;
padding: 0.75rem;
background: #1e293b;
border-radius: 0.5rem;
border: 1px solid #334155;
}
#auth-panel.visible {
display: flex;
}
#auth-panel h2 {
font-size: 0.75rem;
font-weight: 600;
color: #94a3b8;
text-transform: uppercase;
letter-spacing: 0.04em;
}
#auth-fields {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.5rem;
}
#auth-fields input {
width: 100%;
padding: 0.6rem 0.75rem;
border-radius: 0.375rem;
border: 1px solid #334155;
background: #0f172a;
color: #e2e8f0;
font-size: 0.9rem;
outline: none;
}
#auth-fields input:focus {
border-color: #3b82f6;
}
#auth-email {
grid-column: 1 / -1;
}
#auth-name {
grid-column: 1 / -1;
}
#auth-actions {
display: flex;
gap: 0.5rem;
}
#auth-signup {
background: #2563eb;
}
#auth-signin {
background: #059669;
}
#auth-signin:hover {
background: #047857;
}
#auth-status {
font-size: 0.75rem;
font-family: "SF Mono", "Fira Code", monospace;
color: #cbd5e1;
white-space: pre-wrap;
word-break: break-word;
min-height: 2.5rem;
line-height: 1.4;
}
@media (max-width: 640px) {
#auth-fields {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Room Chat</h1>
<div class="header-right">
<button
id="clear-chat"
title="Clear chat"
style="
background: #475569;
font-size: 0.8rem;
padding: 0.35rem 0.75rem;
"
>
Clear
</button>
<button id="toggle">Disconnect</button>
<button id="avatar-btn" title="Account">
<svg viewBox="0 0 24 24">
<path
d="M12 12c2.7 0 4.8-2.1 4.8-4.8S14.7 2.4 12 2.4 7.2 4.5 7.2 7.2 9.3 12 12 12zm0 2.4c-3.2 0-9.6 1.6-9.6 4.8v2.4h19.2v-2.4c0-3.2-6.4-4.8-9.6-4.8z"
/>
</svg>
</button>
</div>
</div>
<span id="status" class="disconnected">disconnected</span>
<div id="auth-panel">
<h2>Auth</h2>
<div id="auth-fields">
<input
id="auth-email"
type="email"
placeholder="Email"
autocomplete="email"
/>
<input
id="auth-password"
type="password"
placeholder="Password"
autocomplete="current-password"
/>
<input
id="auth-name"
type="text"
placeholder="Display name"
autocomplete="name"
/>
</div>
<div id="auth-actions">
<button id="auth-signup" type="button">Sign Up</button>
<button id="auth-signin" type="button">Sign In</button>
</div>
<div id="auth-status">Idle.</div>
</div>
<div id="messages"></div>
<div id="actions">
<button id="increment">Increment Counter</button>
</div>
<div id="reminder-panel">
<h2>Scheduled Reminders</h2>
<div style="display: flex; gap: 0.5rem">
<input
id="reminder-delay"
type="number"
min="1"
value="5"
style="
width: 4rem;
padding: 0.6rem 0.75rem;
border-radius: 0.375rem;
border: 1px solid #334155;
background: #0f172a;
color: #e2e8f0;
font-size: 0.9rem;
outline: none;
"
/>
<span style="align-self: center; color: #94a3b8; font-size: 0.8rem"
>seconds</span
>
<input
id="reminder-msg"
type="text"
placeholder="Reminder message…"
style="
flex: 1;
padding: 0.6rem 0.75rem;
border-radius: 0.375rem;
border: 1px solid #334155;
background: #0f172a;
color: #e2e8f0;
font-size: 0.9rem;
outline: none;
"
/>
<button
id="reminder-send"
type="button"
style="background: #f59e0b; padding: 0.6rem 1rem"
>
Schedule
</button>
</div>
<div
id="reminder-status"
style="
font-size: 0.75rem;
font-family: &quot;SF Mono&quot;, &quot;Fira Code&quot;, monospace;
color: #cbd5e1;
min-height: 1.2rem;
line-height: 1.4;
"
>
Type /remind &lt;seconds&gt; &lt;message&gt; in chat, or use the form
above.
</div>
</div>
<div id="artifacts-panel">
<h2>Artifacts (Git for Agents)</h2>
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap">
<input
id="artifacts-name"
type="text"
placeholder="repo name (e.g. starter-1234)"
style="
flex: 1;
min-width: 12rem;
padding: 0.6rem 0.75rem;
border-radius: 0.375rem;
border: 1px solid #334155;
background: #0f172a;
color: #e2e8f0;
font-size: 0.9rem;
outline: none;
"
/>
<button
id="artifacts-create"
type="button"
style="background: #10b981"
>
Create
</button>
<button id="artifacts-info" type="button" style="background: #6366f1">
Info
</button>
<button
id="artifacts-token"
type="button"
style="background: #eab308; color: #1f2937"
>
New token
</button>
<button id="artifacts-list" type="button" style="background: #475569">
List
</button>
</div>
<div id="artifacts-status">
Create a repo, then mint scoped tokens for git clone/push.
</div>
</div>
<div id="ai-panel">
<h2>AI Gateway</h2>
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap">
<input
id="ai-prompt"
type="text"
placeholder="Ask the model anything…"
style="
flex: 1;
min-width: 12rem;
padding: 0.6rem 0.75rem;
border-radius: 0.375rem;
border: 1px solid #334155;
background: #0f172a;
color: #e2e8f0;
font-size: 0.9rem;
outline: none;
"
/>
<button id="ai-send" type="button" style="background: #f43f5e">
Ask
</button>
</div>
<div id="ai-status">
Routes Workers AI calls through Cloudflare AI Gateway for caching,
rate limiting and observability.
</div>
</div>
<div id="workflow-panel">
<h2>NotifyWorkflow</h2>
<button id="workflow-run" type="button">
Run workflow (broadcasts to this room)
</button>
<div id="workflow-status">
Idle — messages will appear in the chat above.
</div>
</div>
<form id="form">
<input
id="input"
type="text"
placeholder="Type a message…"
autocomplete="off"
/>
<button type="submit">Send</button>
</form>
</div>
<script>
const proto = location.protocol === "https:" ? "wss:" : "ws:";
const BASE = `${proto}//${location.host}`;
const roomId =
new URLSearchParams(location.search).get("room") || "default";
const statusEl = document.getElementById("status");
const messagesEl = document.getElementById("messages");
const form = document.getElementById("form");
const input = document.getElementById("input");
const authEmailEl = document.getElementById("auth-email");
const authPasswordEl = document.getElementById("auth-password");
const authNameEl = document.getElementById("auth-name");
const authSignUpBtn = document.getElementById("auth-signup");
const authSignInBtn = document.getElementById("auth-signin");
const authStatusEl = document.getElementById("auth-status");
function setStatus(s) {
statusEl.textContent = s;
statusEl.className = s;
}
function addMessage(text, system = false) {
const div = document.createElement("div");
div.className = "msg" + (system ? " system" : "");
div.textContent = text;
messagesEl.appendChild(div);
messagesEl.scrollTop = messagesEl.scrollHeight;
}
function setAuthStatus(text) {
authStatusEl.textContent = text;
}
async function readResponseBody(response) {
const text = await response.text();
try {
return JSON.stringify(JSON.parse(text), null, 2);
} catch {
return text || "(empty)";
}
}
function getAuthPayload() {
return {
email: authEmailEl.value.trim(),
password: authPasswordEl.value,
name: authNameEl.value.trim() || "Room Chat User",
};
}
async function submitAuth(path, body, buttonLabel) {
authSignUpBtn.disabled = true;
authSignInBtn.disabled = true;
setAuthStatus(`${buttonLabel}…`);
try {
const response = await fetch(path, {
method: "POST",
headers: {
"content-type": "application/json",
},
credentials: "include",
body: JSON.stringify(body),
});
const payload = await readResponseBody(response);
setAuthStatus(`status: ${response.status}\n${payload}`);
addMessage(`${buttonLabel} -> HTTP ${response.status}`, true);
} catch (error) {
const message =
error instanceof Error ? error.message : String(error);
setAuthStatus(`request failed: ${message}`);
addMessage(`${buttonLabel} failed: ${message}`, true);
} finally {
authSignUpBtn.disabled = false;
authSignInBtn.disabled = false;
}
}
authSignUpBtn.addEventListener("click", async () => {
const { email, password, name } = getAuthPayload();
if (!email || !password) {
setAuthStatus("Email and password are required.");
return;
}
await submitAuth(
"/auth/sign-up/email",
{ email, password, name },
"Sign Up",
);
});
authSignInBtn.addEventListener("click", async () => {
const { email, password } = getAuthPayload();
if (!email || !password) {
setAuthStatus("Email and password are required.");
return;
}
await submitAuth("/auth/sign-in/email", { email, password }, "Sign In");
});
document.getElementById("clear-chat").addEventListener("click", () => {
messagesEl.innerHTML = "";
});
const avatarBtn = document.getElementById("avatar-btn");
const authPanel = document.getElementById("auth-panel");
avatarBtn.addEventListener("click", () => {
authPanel.classList.toggle("visible");
avatarBtn.classList.toggle("active");
});
const toggleBtn = document.getElementById("toggle");
let ws;
let autoReconnect = true;
let reconnectTimer;
function connect() {
setStatus("connecting");
addMessage(`Connecting to room "${roomId}"…`, true);
ws = new WebSocket(`${BASE}/room/${roomId}`);
ws.addEventListener("open", () => {
setStatus("connected");
addMessage("Connected.", true);
});
ws.addEventListener("message", (e) => {
addMessage(e.data);
});
ws.addEventListener("close", (e) => {
setStatus("disconnected");
addMessage(`Disconnected (code ${e.code}).`, true);
if (autoReconnect) {
reconnectTimer = setTimeout(connect, 2000);
}
});
ws.addEventListener("error", () => {
setStatus("disconnected");
addMessage("WebSocket error.", true);
});
}
toggleBtn.addEventListener("click", () => {
if (autoReconnect) {
autoReconnect = false;
toggleBtn.textContent = "Connect";
toggleBtn.className = "off";
clearTimeout(reconnectTimer);
if (ws && ws.readyState === WebSocket.OPEN) {
ws.close();
}
} else {
autoReconnect = true;
toggleBtn.textContent = "Disconnect";
toggleBtn.className = "";
if (!ws || ws.readyState !== WebSocket.OPEN) {
connect();
}
}
});
const incrementBtn = document.getElementById("increment");
incrementBtn.addEventListener("click", async () => {
incrementBtn.disabled = true;
incrementBtn.textContent = "Incrementing…";
try {
const resp = await fetch("/sandbox/increment", { method: "POST" });
const data = await resp.json();
addMessage(`Counter incremented to ${data.counter}`, true);
} catch (err) {
addMessage(`Increment failed: ${err.message}`, true);
} finally {
incrementBtn.disabled = false;
incrementBtn.textContent = "Increment Counter";
}
});
const workflowRunBtn = document.getElementById("workflow-run");
const workflowStatusEl = document.getElementById("workflow-status");
function isWorkflowTerminal(status) {
if (!status || typeof status.status !== "string") return false;
const s = status.status.toLowerCase();
return (
s === "complete" ||
s === "completed" ||
s === "errored" ||
s === "failed" ||
s === "terminated" ||
s === "canceled" ||
s === "cancelled" ||
(status.error != null && status.error !== undefined)
);
}
function formatWorkflowStatus(status) {
const lines = [
`status: ${status.status ?? "(none)"}`,
status.output !== undefined
? `output: ${JSON.stringify(status.output)}`
: null,
status.error ? `error: ${JSON.stringify(status.error)}` : null,
].filter(Boolean);
return lines.join("\n");
}
let workflowPollTimer = null;
workflowRunBtn.addEventListener("click", async () => {
if (workflowPollTimer) {
clearTimeout(workflowPollTimer);
workflowPollTimer = null;
}
workflowRunBtn.disabled = true;
workflowStatusEl.textContent = "Starting workflow…";
addMessage(
`Starting NotifyWorkflow for room "${roomId}" (watch for [workflow] lines)…`,
true,
);
try {
const startResp = await fetch(
`/workflow/start/${encodeURIComponent(roomId)}`,
{ method: "POST" },
);
if (!startResp.ok) {
const errText = await startResp.text();
throw new Error(errText || `HTTP ${startResp.status}`);
}
const { instanceId } = await startResp.json();
if (!instanceId) {
throw new Error("No instanceId in response");
}
workflowStatusEl.textContent = `instance: ${instanceId}\npolling…`;
let pollCount = 0;
const maxPolls = 120;
const poll = async () => {
try {
pollCount += 1;
if (pollCount > maxPolls) {
workflowStatusEl.textContent = `instance: ${instanceId}\n(stopped polling after ${maxPolls} attempts — check logs)`;
workflowRunBtn.disabled = false;
addMessage("Workflow status polling timed out.", true);
return;
}
const stResp = await fetch(
`/workflow/status/${encodeURIComponent(instanceId)}`,
);
if (!stResp.ok) {
workflowStatusEl.textContent = `status fetch failed: HTTP ${stResp.status}`;
workflowRunBtn.disabled = false;
return;
}
const st = await stResp.json();
workflowStatusEl.textContent =
`instance: ${instanceId}\n` + formatWorkflowStatus(st);
if (isWorkflowTerminal(st)) {
workflowRunBtn.disabled = false;
addMessage(
`Workflow finished: ${st.status}` +
(st.output !== undefined
? ` — ${JSON.stringify(st.output)}`
: ""),
true,
);
return;
}
workflowPollTimer = setTimeout(poll, 600);
} catch (e) {
workflowStatusEl.textContent = `poll error: ${e.message}`;
workflowRunBtn.disabled = false;
}
};
await poll();
} catch (err) {
workflowStatusEl.textContent = `Error: ${err.message}`;
addMessage(`Workflow start failed: ${err.message}`, true);
workflowRunBtn.disabled = false;
}
});
const artifactsNameEl = document.getElementById("artifacts-name");
const artifactsStatusEl = document.getElementById("artifacts-status");
const artifactsCreateBtn = document.getElementById("artifacts-create");
const artifactsInfoBtn = document.getElementById("artifacts-info");
const artifactsTokenBtn = document.getElementById("artifacts-token");
const artifactsListBtn = document.getElementById("artifacts-list");
function setArtifactsStatus(label, payload) {
const body =
typeof payload === "string"
? payload
: JSON.stringify(payload, null, 2);
artifactsStatusEl.textContent = `${label}\n${body}`;
}
async function callArtifacts(method, path, body) {
const init = { method };
if (body !== undefined) {
init.headers = { "content-type": "application/json" };
init.body = JSON.stringify(body);
}
const resp = await fetch(path, init);
const text = await resp.text();
let parsed;
try {
parsed = JSON.parse(text);
} catch {
parsed = text;
}
return { status: resp.status, body: parsed };
}
function getRepoName() {
const name = artifactsNameEl.value.trim();
if (!name) {
setArtifactsStatus("error", "repo name is required");
return null;
}
return name;
}
artifactsCreateBtn.addEventListener("click", async () => {
const name = getRepoName();
if (!name) return;
artifactsCreateBtn.disabled = true;
setArtifactsStatus(`POST /repos/create ${name}`, "...");
try {
const { status, body } = await callArtifacts(
"POST",
"/repos/create",
{ name, description: "Created from Room Chat" },
);
setArtifactsStatus(`HTTP ${status}`, body);
addMessage(
`Artifacts: created repo "${name}" -> HTTP ${status}`,
true,
);
} catch (e) {
setArtifactsStatus("request failed", e.message);
} finally {
artifactsCreateBtn.disabled = false;
}
});
artifactsInfoBtn.addEventListener("click", async () => {
const name = getRepoName();
if (!name) return;
artifactsInfoBtn.disabled = true;
setArtifactsStatus(`GET /repos/info?name=${name}`, "...");
try {
const { status, body } = await callArtifacts(
"GET",
`/repos/info?name=${encodeURIComponent(name)}`,
);
setArtifactsStatus(`HTTP ${status}`, body);
} catch (e) {
setArtifactsStatus("request failed", e.message);
} finally {
artifactsInfoBtn.disabled = false;
}
});
artifactsTokenBtn.addEventListener("click", async () => {
const name = getRepoName();
if (!name) return;
artifactsTokenBtn.disabled = true;
setArtifactsStatus(`POST /repos/token ${name}`, "...");
try {
const { status, body } = await callArtifacts("POST", "/repos/token", {
name,
scope: "write",
ttl: 3600,
});
setArtifactsStatus(`HTTP ${status}`, body);
} catch (e) {
setArtifactsStatus("request failed", e.message);
} finally {
artifactsTokenBtn.disabled = false;
}
});
artifactsListBtn.addEventListener("click", async () => {
artifactsListBtn.disabled = true;
setArtifactsStatus("GET /repos/list", "...");
try {
const { status, body } = await callArtifacts("GET", "/repos/list");
setArtifactsStatus(`HTTP ${status}`, body);
} catch (e) {
setArtifactsStatus("request failed", e.message);
} finally {
artifactsListBtn.disabled = false;
}
});
const aiPromptEl = document.getElementById("ai-prompt");
const aiSendBtn = document.getElementById("ai-send");
const aiStatusEl = document.getElementById("ai-status");
aiSendBtn.addEventListener("click", async () => {
const prompt = aiPromptEl.value.trim();
if (!prompt) {
aiStatusEl.textContent = "Enter a prompt first.";
return;
}
aiSendBtn.disabled = true;
aiStatusEl.textContent = `> ${prompt}\n…`;
try {
const resp = await fetch("/ai", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ prompt }),
});
const text = await resp.text();
let parsed;
try {
parsed = JSON.parse(text);
} catch {
parsed = text;
}
const out =
typeof parsed === "object" && parsed && "response" in parsed
? parsed.response
: typeof parsed === "string"
? parsed
: JSON.stringify(parsed, null, 2);
aiStatusEl.textContent = `> ${prompt}\n${out}`;
addMessage(`AI Gateway -> HTTP ${resp.status}`, true);
} catch (e) {
aiStatusEl.textContent = `Error: ${e.message}`;
} finally {
aiSendBtn.disabled = false;
}
});
const reminderSendBtn = document.getElementById("reminder-send");
const reminderDelayEl = document.getElementById("reminder-delay");
const reminderMsgEl = document.getElementById("reminder-msg");
const reminderStatusEl = document.getElementById("reminder-status");
reminderSendBtn.addEventListener("click", () => {
const delay = parseInt(reminderDelayEl.value, 10);
const msg = reminderMsgEl.value.trim() || "ping!";
if (!delay || delay < 1) {
reminderStatusEl.textContent = "Delay must be at least 1 second.";
return;
}
if (!ws || ws.readyState !== WebSocket.OPEN) {
reminderStatusEl.textContent = "Not connected.";
return;
}
ws.send(`/remind ${delay} ${msg}`);
reminderStatusEl.textContent = `Sent: /remind ${delay} ${msg}`;
reminderMsgEl.value = "";
});
form.addEventListener("submit", (e) => {
e.preventDefault();
const text = input.value.trim();
if (!text || !ws || ws.readyState !== WebSocket.OPEN) return;
ws.send(text);
input.value = "";
});
connect();
</script>
</body>
</html>