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/Ideas.tsx
// The board's ideas: post one (with similar ideas suggested first), vote, and find them by search, sort,
// status and tag.
import { useMemo, useRef, useState } from "react";
import { setMany, serverTimestamp } from "@bounded-sh/client";
import { explain } from "./lib/bounded";
import { savedName, saveName } from "./lib/mine";
import { castVote, removeVote } from "./lib/votes";
import { ago, plural } from "./lib/format";
import { toast } from "./ui";
import { useBoard, boardPath, goPost, AccountNote, Empty } from "./Board";
import {
STATUSES, STATUS_LABEL, ACTIVE, SORTS, SORT_LABEL, LIMITS, randomId, parseIds, sortPosts, matchesSearch, similarPosts, listed,
type Post, type Sort, type Status,
} from "../functions/lib/feedback";
type StatusFilter = "active" | "all" | "mine" | Status;
export function Ideas({ compact = false }: { compact?: boolean }) {
const { posts, tags, voted } = useBoard();
const [q, setQ] = useState("");
const [sort, setSort] = useState<Sort>("top");
const [status, setStatus] = useState<StatusFilter>("active");
const [tag, setTag] = useState<string | null>(null);
const visible = useMemo(() => listed(posts), [posts]);
const shown = useMemo(() => {
const names = (p: Post) => tags.filter((t) => parseIds(p.tags).includes(t.id)).map((t) => t.name).join(" ");
return sortPosts(visible, sort).filter((p) =>
(status === "all" || (status === "active" ? ACTIVE.includes(p.status) : status === "mine" ? voted.has(p.id) : p.status === status))
&& (!tag || parseIds(p.tags).includes(tag))
&& matchesSearch(p, q, names(p)));
}, [visible, sort, status, tag, q, tags, voted]);
const filtered = q || tag || status !== "active";
return (
<div className={compact ? "ideas ideas-compact" : "ideas"}>
<aside className="ideas-side">
<Composer compact={compact} />
</aside>
<section className="ideas-main" aria-label="Ideas">
<div className="toolbar">
<label className="search">
<svg width="16" height="16" viewBox="0 0 20 20" aria-hidden="true"><circle cx="9" cy="9" r="5.8" fill="none" stroke="currentColor" strokeWidth="1.6" /><path d="m13.5 13.5 3.5 3.5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" /></svg>
<span className="sr-only">Search ideas</span>
<input className="input" type="search" placeholder="Search ideas" value={q} onChange={(e) => setQ(e.target.value)} />
</label>
<div className="seg" role="group" aria-label="Sort">
{SORTS.map((s) => (
<button key={s} type="button" className={sort === s ? "on" : ""} aria-pressed={sort === s} onClick={() => setSort(s)}>{SORT_LABEL[s]}</button>
))}
</div>
<select className="select select-inline" value={status} onChange={(e) => setStatus(e.target.value as StatusFilter)} aria-label="Show">
<option value="active">Open ideas</option>
<option value="all">Every status</option>
{STATUSES.map((s) => <option key={s} value={s}>{STATUS_LABEL[s]}</option>)}
<option value="mine">Ideas I voted for</option>
</select>
</div>
{tags.length > 0 && (
<div className="tag-filter" role="group" aria-label="Filter by tag">
{tags.map((t) => (
<button key={t.id} type="button" className={`chip chip-btn${tag === t.id ? " on" : ""}`} aria-pressed={tag === t.id} onClick={() => setTag(tag === t.id ? null : t.id)}>{t.name}</button>
))}
</div>
)}
{shown.length ? (
<ul className="post-list">
{shown.map((p) => <PostRow key={p.id} post={p} compact={compact} />)}
</ul>
) : visible.length ? (
<div className="empty empty-small">
<p className="h3">{filtered ? "No ideas match" : "No open ideas right now"}</p>
{filtered
? <button className="link-btn" type="button" onClick={() => { setQ(""); setTag(null); setStatus("active"); }}>Clear the filters</button>
: <button className="link-btn" type="button" onClick={() => setStatus("all")}>Show done and closed ideas</button>}
</div>
) : (
<Empty title="No ideas yet">
<p className="muted">Be the first: post an idea or a bug, and others can vote for it.</p>
</Empty>
)}
{shown.length > 0 && <p className="list-foot">{plural(shown.length, "idea")} shown{filtered ? `, out of ${visible.length}` : ""}.</p>}
</section>
{!compact && <div className="ideas-account"><AccountNote /></div>}
</div>
);
}
export function VoteButton({ post, size = "md" }: { post: Post; size?: "md" | "lg" }) {
const { boardId, user, voted } = useBoard();
const [busy, setBusy] = useState(false);
const on = voted.has(post.id);
const closed = Boolean(post.merged);
const toggle = async () => {
if (busy || closed) return;
setBusy(true);
try {
if (on) await removeVote(boardId, post.id, user.id);
else await castVote(boardId, post.id, user.id);
} catch (e) {
toast(explain(e));
} finally {
setBusy(false);
}
};
return (
<button type="button" className={`vote vote-${size}${on ? " on" : ""}`} onClick={toggle} disabled={busy || closed} aria-pressed={on}
aria-label={`${on ? "Remove your vote for" : "Vote for"} ${post.title}. ${plural(post.votes, "vote")}.`} title={on ? "You voted. Click to take it back." : "Vote for this idea"}>
<svg width="14" height="14" viewBox="0 0 20 20" aria-hidden="true"><path d="M4.5 12.5 10 7l5.5 5.5" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" /></svg>
<span className="vote-count">{post.votes}</span>
{size === "lg" && <span className="vote-word">{on ? "Voted" : "Vote"}</span>}
</button>
);
}
export function StatusChip({ status }: { status: Status }) {
return <span className={`status status-${status}`}>{STATUS_LABEL[status]}</span>;
}
function PostRow({ post, compact }: { post: Post; compact: boolean }) {
const { boardId, tags } = useBoard();
const ids = parseIds(post.tags);
const href = boardPath(boardId, `/p/${post.id}`);
return (
<li className="post-row">
<VoteButton post={post} />
<div className="post-row-body">
<a className="post-row-title" href={href} data-nav>{post.title}</a>
{!compact && post.details && <p className="post-row-snippet">{post.details.replace(/[#*_`>\[\]]/g, "").slice(0, 220)}</p>}
<p className="post-row-meta">
{post.status !== "new" && <StatusChip status={post.status} />}
{post.pinned && <span className="flag">Pinned</span>}
{post.hidden && <span className="flag flag-hidden">Hidden</span>}
{tags.filter((t) => ids.includes(t.id)).map((t) => <span key={t.id} className="tag">{t.name}</span>)}
<a className="meta-link" href={href} data-nav>
<svg width="14" height="14" viewBox="0 0 20 20" aria-hidden="true"><path d="M4 5.5h12v7.5H9l-3.5 3v-3H4z" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" /></svg>
{post.comments}<span className="sr-only"> comments</span>
</a>
{!compact && <span className="muted">{post.name || "Guest"}, {ago(post.createdAt)}</span>}
</p>
</div>
</li>
);
}
function Composer({ compact }: { compact: boolean }) {
const { boardId, user, posts, tags } = useBoard();
const [open, setOpen] = useState(false);
const [title, setTitle] = useState("");
const [details, setDetails] = useState("");
const [picked, setPicked] = useState<string[]>([]);
const [name, setName] = useState(savedName);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const titleRef = useRef<HTMLInputElement>(null);
const similar = useMemo(() => (title.trim().length > 3 ? similarPosts(listed(posts), title) : []), [posts, title]);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
const t = title.trim();
if (!t) { setError("Give your idea a short title."); titleRef.current?.focus(); return; }
setBusy(true);
setError(null);
const postId = randomId(12);
try {
await setMany([
{
path: `boards/${boardId}/posts/${postId}`,
document: {
title: t, details: details.trim(), tags: JSON.stringify(picked), status: "new", statusAt: serverTimestamp(),
votes: 1, comments: 0, pinned: false, hidden: false, name: name.trim(), by: user.id, createdAt: serverTimestamp(),
},
},
{ path: `boards/${boardId}/posts/${postId}/votes/${user.id}`, document: { at: serverTimestamp() } },
{ path: `boards/${boardId}/people/${user.id}/votes/${postId}`, document: { at: serverTimestamp() } },
]);
saveName(name);
setTitle(""); setDetails(""); setPicked([]); setOpen(false);
toast("Posted. Your vote is on it.");
if (!compact) goPost(boardId, postId);
} catch (err) {
setError(explain(err));
} finally {
setBusy(false);
}
};
return (
<form className="card composer" onSubmit={submit}>
<h2 className="composer-title">Post an idea</h2>
<label className="field">
<span className="sr-only">Title</span>
<input ref={titleRef} className="input" value={title} maxLength={LIMITS.title} placeholder="Suggest an idea or report a bug"
onFocus={() => setOpen(true)} onChange={(e) => { setTitle(e.target.value); setOpen(true); setError(null); }} />
</label>
{similar.length > 0 && (
<div className="similar">
<p className="hint">Already posted? Vote for it instead:</p>
<ul>
{similar.map((p) => (
<li key={p.id}>
<VoteButton post={p} />
<a href={boardPath(boardId, `/p/${p.id}`)} data-nav target={compact ? "_blank" : undefined}>{p.title}</a>
</li>
))}
</ul>
</div>
)}
{open && (
<>
<label className="field">
<span>Details <span className="optional">optional</span></span>
<textarea className="textarea" value={details} maxLength={LIMITS.details} rows={4}
placeholder="What are you trying to do? What gets in the way?" onChange={(e) => setDetails(e.target.value)} />
</label>
{tags.length > 0 && (
<div className="field">
<span>Tag <span className="optional">optional</span></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>
)}
<label className="field">
<span>Your name <span className="optional">optional</span></span>
<input className="input" value={name} maxLength={LIMITS.name} placeholder="Shown with your idea" onChange={(e) => setName(e.target.value)} />
</label>
{error && <p className="notice notice-error" role="alert">{error}</p>}
<div className="row composer-actions">
<button className="btn" type="submit" disabled={busy}>{busy ? "Posting..." : "Post idea"}</button>
<button className="btn btn-quiet" type="button" onClick={() => { setOpen(false); setError(null); }}>Cancel</button>
</div>
</>
)}
</form>
);
}
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