Source
This app’s code and revision history are public. Download a copy or explore the files below.
Public
The app’s code and revision history are public.
Run it yourself
Take the whole tree and run it on your own machine. The download needs no account. Cloning needs a sign-in.
Download .zip
bounded clone 54b9c9d7fdc2b68029ce2e57
A copy you download is not bounded. The guarantees this app runs under - declared egress, proven invariants, writes that fail closed - live in the runtime serving this page, not in the files. What you get is auditable source you can read and run, with no runtime guarantees attached.
src/Admin.tsx
// Owner tools: board settings and tags, the website snippets, merging duplicates, editing an idea and
// writing changelog entries.
import { useMemo, useState } from "react";
import { get, set, setMany, increment, serverTimestamp } from "@bounded-sh/client";
import { explain } from "./lib/bounded";
import { navigate } from "./lib/router";
import { forgetBoard } from "./lib/mine";
import { plural } from "./lib/format";
import { Modal, toast, copy } from "./ui";
import { useBoard, boardPath } from "./Board";
import { StatusChip } from "./Ideas";
import { SITE_URL, API_BASE } from "../functions/lib/config";
import { LIMITS, randomId, parseIds, cleanTagNames, matchesSearch, sortPosts, listed, type Entry, type Post, type Tag } from "../functions/lib/feedback";
export function SettingsSheet({ adminUrl, onClose }: { adminUrl: string | null; onClose: () => void }) {
const { boardId, board, tags } = useBoard();
const [name, setName] = useState(board.name);
const [intro, setIntro] = useState(board.intro);
const [list, setList] = useState<Tag[]>(tags);
const [newTag, setNewTag] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const addTag = () => {
const [clean] = cleanTagNames([newTag]);
if (!clean) return;
if (list.some((t) => t.name.toLowerCase() === clean.toLowerCase())) { setNewTag(""); return; }
if (list.length >= LIMITS.tags) { setError(`A board can have up to ${LIMITS.tags} tags.`); return; }
setList([...list, { id: randomId(6), name: clean }]);
setNewTag("");
};
const save = async () => {
if (!name.trim()) { setError("Give the board a name."); return; }
setBusy(true);
setError(null);
try {
const kept = list.map((t) => ({ ...t, name: cleanTagNames([t.name])[0] ?? "" })).filter((t) => t.name);
await set(`boards/${boardId}`, { name: name.trim(), intro: intro.trim(), tags: JSON.stringify(kept) });
toast("Saved");
onClose();
} catch (e) {
setError(explain(e));
setBusy(false);
}
};
return (
<Modal title="Board settings" onClose={onClose} footer={
<>
<button className="btn btn-line" type="button" onClick={onClose}>Cancel</button>
<button className="btn" type="button" onClick={save} disabled={busy}>{busy ? "Saving..." : "Save"}</button>
</>
}>
<div className="stack">
<label className="field">
<span>Name</span>
<input className="input" value={name} maxLength={LIMITS.boardName} onChange={(e) => setName(e.target.value)} />
</label>
<label className="field">
<span>Intro <span className="optional">optional</span></span>
<textarea className="textarea" rows={3} value={intro} maxLength={LIMITS.intro} placeholder="What kind of feedback you want, and how you use it" onChange={(e) => setIntro(e.target.value)} />
</label>
<div className="field">
<span>Tags</span>
<small>People pick a tag when they post. Removing a tag takes it off every idea.</small>
<ul className="tag-edit">
{list.map((t, i) => (
<li key={t.id}>
<input className="input input-sm" value={t.name} maxLength={LIMITS.tagName} aria-label={`Tag ${i + 1}`}
onChange={(e) => setList(list.map((x) => (x.id === t.id ? { ...x, name: e.target.value } : x)))} />
<button className="btn btn-sm btn-quiet" type="button" onClick={() => setList(list.filter((x) => x.id !== t.id))}>Remove</button>
</li>
))}
</ul>
<div className="row tag-add">
<input className="input input-sm" value={newTag} maxLength={LIMITS.tagName} placeholder="New tag, like Mobile"
onChange={(e) => setNewTag(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addTag(); } }} />
<button className="btn btn-sm btn-line" type="button" onClick={addTag}>Add tag</button>
</div>
</div>
{adminUrl && (
<div className="field">
<span>Admin link</span>
<small>Open it on another device, or give it to a teammate, to manage this board there. Anyone with it can manage the board, so keep it private.</small>
<div className="row">
<button className="btn btn-sm btn-line" type="button" onClick={() => copy(adminUrl, "Admin link copied. Keep it private.")}>Copy admin link</button>
<button className="btn btn-sm btn-quiet" type="button" onClick={() => {
if (!confirm("Remove this board from this browser's list? The board keeps working. You will need the admin link to manage it here again.")) return;
forgetBoard(boardId);
navigate("/");
}}>Remove from this browser</button>
</div>
</div>
)}
{error && <p className="notice notice-error" role="alert">{error}</p>}
</div>
</Modal>
);
}
function CodeBox({ code, label }: { code: string; label: string }) {
return (
<div className="codebox">
<div className="codebox-bar">
<span>{label}</span>
<button className="btn btn-sm btn-line" type="button" onClick={() => copy(code, `${label} copied`)}>Copy</button>
</div>
<pre className="code">{code}</pre>
</div>
);
}
export function EmbedSheet({ onClose }: { onClose: () => void }) {
const { boardId, board, tags } = useBoard();
const url = `${SITE_URL}${boardPath(boardId)}`;
const button = `<!-- Feedback button: opens your board in a small window, so visitors stay on your page. -->
<a href="${url}" target="_blank" rel="noopener"
onclick="window.open('${url}/embed', 'feedback', 'width=460,height=720'); return false;"
style="display:inline-block;padding:10px 16px;border-radius:10px;background:#0b0b0c;color:#fff;font:600 14px system-ui,sans-serif;text-decoration:none">
Give feedback
</a>`;
const form = `<!-- Feedback form: posts an idea to your board from your own page. No script needed. -->
<form action="${API_BASE}/boards/${boardId}/posts" method="POST">
<input name="title" placeholder="Your idea, in a few words" maxlength="140" required>
<textarea name="details" placeholder="Details (optional)" rows="3"></textarea>${tags.length ? `
<select name="tag">
<option value="">Tag (optional)</option>
${tags.map((t) => ` <option>${t.name.replace(/[<>&"]/g, "")}</option>`).join("\n")}
</select>` : ""}
<input name="name" placeholder="Your name (optional)">
<!-- Leave this hidden field empty: bots fill it in and get ignored. -->
<input name="_gotcha" style="display:none" tabindex="-1" autocomplete="off">
<button type="submit">Send idea</button>
</form>`;
return (
<Modal title="Add the board to your website" onClose={onClose} footer={<button className="btn" type="button" onClick={onClose}>Done</button>}>
<div className="stack">
<p className="muted">Two ways to collect feedback on your own site. Both work on any website, with no account and no limits.</p>
<CodeBox label="Button" code={button} />
<p className="hint">The button opens <a href={`${boardPath(boardId)}/embed`} target="_blank" rel="noopener">a compact version of "{board.name}"</a> in a small window. People post, vote and see statuses there, and your page stays where it was. Without scripts it opens the full board in a new tab.</p>
<CodeBox label="Form" code={form} />
<p className="hint">The form posts ideas straight onto your board. After sending, people see a short thank-you page with a link to their idea and back to your site.</p>
<p className="hint">Why not an iframe? The hosting does not let other websites frame these pages, so nobody can dress them up inside a site to trick people. The button and the form give the same result.</p>
</div>
</Modal>
);
}
const rows = (res: any): any[] => (Array.isArray(res) ? res : res?.data ?? []);
async function voterIds(boardId: string, postId: string): Promise<string[]> {
const ids: string[] = [];
let cursor: string | undefined;
for (let page = 0; page < 50; page++) {
const res = await get(`boards/${boardId}/posts/${postId}/votes`, { limit: 1000, ...(cursor ? { cursor } : {}) });
for (const r of rows(res)) ids.push(String(r.id ?? String(r._id ?? "").split("/").pop()));
cursor = res?.nextCursor;
if (!cursor) break;
}
return ids;
}
/** Merges a duplicate into another idea: every vote on it that the other idea lacks moves there. */
export function MergeSheet({ source, onClose }: { source: Post; onClose: () => void }) {
const { boardId, posts } = useBoard();
const [q, setQ] = useState("");
const [target, setTarget] = useState<Post | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const options = useMemo(() => sortPosts(listed(posts).filter((p) => p.id !== source.id && matchesSearch(p, q)), "top").slice(0, 30), [posts, q, source.id]);
const merge = async () => {
if (!target) return;
setBusy(true);
setError(null);
try {
const [from, already] = await Promise.all([voterIds(boardId, source.id), voterIds(boardId, target.id)]);
const have = new Set(already);
const moving = from.filter((u) => !have.has(u));
for (let i = 0; i < moving.length; i += 50) {
const chunk = moving.slice(i, i + 50);
await setMany([
...chunk.flatMap((u) => [
{ path: `boards/${boardId}/posts/${target.id}/votes/${u}`, document: { at: serverTimestamp(), from: source.id } },
{ path: `boards/${boardId}/people/${u}/votes/${target.id}`, document: { at: serverTimestamp() } },
]),
{ path: `boards/${boardId}/posts/${target.id}`, document: { votes: increment(chunk.length) } },
]);
}
await set(`boards/${boardId}/posts/${source.id}`, { merged: target.id, status: "closed", statusAt: serverTimestamp(), pinned: false });
toast(`Merged. ${moving.length ? `${plural(moving.length, "vote")} moved over.` : "Everyone who voted already voted for it."}`);
onClose();
navigate(boardPath(boardId, `/p/${target.id}`));
} catch (e) {
setError(explain(e));
setBusy(false);
}
};
return (
<Modal title="Merge into another idea" onClose={onClose} footer={
<>
<button className="btn btn-line" type="button" onClick={onClose}>Cancel</button>
<button className="btn" type="button" onClick={merge} disabled={!target || busy}>{busy ? "Merging..." : "Merge"}</button>
</>
}>
<div className="stack">
<p className="muted">"{source.title}" closes and points to the idea you pick. Its votes move there (people who voted for both count once), and its comments stay on it.</p>
<input className="input" type="search" placeholder="Search ideas" value={q} onChange={(e) => setQ(e.target.value)} aria-label="Search ideas" />
<ul className="pick-list" role="listbox" aria-label="Ideas">
{options.map((p) => (
<li key={p.id}>
<button type="button" role="option" aria-selected={target?.id === p.id} className={target?.id === p.id ? "on" : ""} onClick={() => setTarget(p)}>
<span className="pick-votes">{p.votes}</span>
<span className="pick-title">{p.title}</span>
{p.status !== "new" && <StatusChip status={p.status} />}
</button>
</li>
))}
{!options.length && <li className="muted pick-empty">No other ideas match.</li>}
</ul>
{error && <p className="notice notice-error" role="alert">{error}</p>}
</div>
</Modal>
);
}
export function EditPostSheet({ post, onClose }: { post: Post; onClose: () => void }) {
const { boardId, tags } = useBoard();
const [title, setTitle] = useState(post.title);
const [details, setDetails] = useState(post.details);
const [picked, setPicked] = useState<string[]>(parseIds(post.tags));
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const save = async () => {
if (!title.trim()) { setError("An idea needs a title."); return; }
setBusy(true);
try {
await set(`boards/${boardId}/posts/${post.id}`, { title: title.trim(), details: details.trim(), tags: JSON.stringify(picked.filter((id) => tags.some((t) => t.id === id))) });
toast("Saved");
onClose();
} catch (e) {
setError(explain(e));
setBusy(false);
}
};
return (
<Modal title="Edit idea" onClose={onClose} footer={
<>
<button className="btn btn-line" type="button" onClick={onClose}>Cancel</button>
<button className="btn" type="button" onClick={save} disabled={busy}>{busy ? "Saving..." : "Save"}</button>
</>
}>
<div className="stack">
<label className="field"><span>Title</span><input className="input" value={title} maxLength={LIMITS.title} onChange={(e) => setTitle(e.target.value)} /></label>
<label className="field"><span>Details</span><textarea className="textarea" rows={6} value={details} maxLength={LIMITS.details} onChange={(e) => setDetails(e.target.value)} /></label>
{tags.length > 0 && (
<div className="field">
<span>Tags</span>
<div className="row tag-pick">
{tags.map((t) => {
const on = picked.includes(t.id);
return <button key={t.id} type="button" className={`chip chip-btn${on ? " on" : ""}`} aria-pressed={on} onClick={() => setPicked(on ? picked.filter((x) => x !== t.id) : [...picked, t.id])}>{t.name}</button>;
})}
</div>
</div>
)}
{error && <p className="notice notice-error" role="alert">{error}</p>}
</div>
</Modal>
);
}
export function EntrySheet({ entry, onClose }: { entry: Entry | null; onClose: () => void }) {
const { boardId, posts, user } = useBoard();
const [title, setTitle] = useState(entry?.title ?? "");
const [body, setBody] = useState(entry?.body ?? "");
const [linked, setLinked] = useState<string[]>(entry ? parseIds(entry.posts) : []);
const [markDone, setMarkDone] = useState(true);
const [q, setQ] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
// In progress and planned ideas first: they are the likeliest to have shipped.
const rank = (p: Post) => ({ progress: 0, planned: 1, review: 2, new: 3, done: 4, closed: 5 })[p.status];
const options = useMemo(() => listed(posts).filter((p) => matchesSearch(p, q)).sort((a, b) => rank(a) - rank(b) || b.votes - a.votes).slice(0, 40), [posts, q]);
const save = async () => {
if (!title.trim()) { setError("Give the update a title, like \"Dark mode is here\"."); return; }
if (linked.length > LIMITS.entryPosts) { setError(`Link up to ${LIMITS.entryPosts} ideas.`); return; }
setBusy(true);
setError(null);
try {
const doc = { title: title.trim(), body: body.trim(), posts: JSON.stringify(linked) };
const finish = markDone
? linked.filter((id) => posts.find((p) => p.id === id && p.status !== "done")).map((id) => ({ path: `boards/${boardId}/posts/${id}`, document: { status: "done", statusAt: serverTimestamp() } }))
: [];
await setMany([
{ path: `boards/${boardId}/changelog/${entry?.id ?? randomId(12)}`, document: entry ? doc : { ...doc, at: serverTimestamp(), by: user.id } },
...finish,
]);
toast(entry ? "Saved" : "Published");
onClose();
} catch (e) {
setError(explain(e));
setBusy(false);
}
};
return (
<Modal title={entry ? "Edit update" : "Write an update"} onClose={onClose} footer={
<>
<button className="btn btn-line" type="button" onClick={onClose}>Cancel</button>
<button className="btn" type="button" onClick={save} disabled={busy}>{busy ? "Saving..." : entry ? "Save" : "Publish"}</button>
</>
}>
<div className="stack">
<label className="field"><span>Title</span><input className="input" value={title} maxLength={LIMITS.entryTitle} placeholder="Dark mode is here" onChange={(e) => setTitle(e.target.value)} /></label>
<label className="field">
<span>What changed <span className="optional">optional, Markdown works</span></span>
<textarea className="textarea" rows={5} value={body} maxLength={LIMITS.entryBody} onChange={(e) => setBody(e.target.value)} />
</label>
<div className="field">
<span>Ideas it shipped <span className="optional">{linked.length ? `${linked.length} picked` : "optional"}</span></span>
<input className="input input-sm" type="search" placeholder="Search ideas" value={q} onChange={(e) => setQ(e.target.value)} aria-label="Search ideas to link" />
<ul className="pick-list pick-checks">
{options.map((p) => {
const on = linked.includes(p.id);
return (
<li key={p.id}>
<label className={on ? "on" : ""}>
<input type="checkbox" checked={on} onChange={() => setLinked(on ? linked.filter((x) => x !== p.id) : [...linked, p.id])} />
<span className="pick-votes">{p.votes}</span>
<span className="pick-title">{p.title}</span>
{p.status !== "new" && <StatusChip status={p.status} />}
</label>
</li>
);
})}
{!options.length && <li className="muted pick-empty">No ideas match.</li>}
</ul>
{linked.length > 0 && <label className="check check-sm"><input type="checkbox" checked={markDone} onChange={(e) => setMarkDone(e.target.checked)} /> Mark the linked ideas as done</label>}
</div>
{error && <p className="notice notice-error" role="alert">{error}</p>}
</div>
</Modal>
);
}
Change history
Point store links to openapps.free
Release with preview
Retry release
Bind app agent address in policy
Use OpenApps service URLs and add contribution strip
Bounded source sync
Bounded source sync
Bounded source sync
Bounded source sync
Bounded source sync