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/Post.tsx
// One idea: its votes, details, status, the changelog entries that shipped it, and the comments. Owners
// also set its status and tags, pin, hide, merge it into another idea, edit or delete it.
import { lazy, Suspense, useMemo, useState } from "react";
import { setMany, set, useQuery, increment, serverTimestamp } from "@bounded-sh/client";
import { explain } from "./lib/bounded";
import { savedName, saveName } from "./lib/mine";
import { ago, date, plural } from "./lib/format";
import { navigate } from "./lib/router";
import { toast, Modal } from "./ui";
import { useBoard, boardPath } from "./Board";
import { VoteButton, StatusChip } from "./Ideas";
import { MergeSheet, EditPostSheet } from "./Admin";
import { STATUSES, STATUS_LABEL, LIMITS, randomId, parseIds, type Comment, type Status } from "../functions/lib/feedback";
const Markdown = lazy(() => import("./Markdown"));
export function Rich({ text, className }: { text: string; className?: string }) {
return (
<Suspense fallback={<div className={`md plain ${className ?? ""}`}>{text}</div>}>
<Markdown text={text} className={className} />
</Suspense>
);
}
const ALL = { limit: 1000 };
export function PostPage({ postId }: { postId: string }) {
const { boardId, posts, tags, entries, owner, user } = useBoard();
const post = posts.find((p) => p.id === postId);
const { data: comments } = useQuery<Comment[]>(post ? `boards/${boardId}/posts/${postId}/comments` : null, ALL);
const [sheet, setSheet] = useState<"merge" | "edit" | null>(null);
const shipped = useMemo(() => entries.filter((e) => parseIds(e.posts).includes(postId)), [entries, postId]);
const mergedIn = useMemo(() => posts.filter((p) => p.merged === postId), [posts, postId]);
const back = <a className="back" href={boardPath(boardId)} data-nav>All ideas</a>;
if (!post) return (
<div className="post-page narrow">
{back}
<h2 className="h2" style={{ marginTop: 18 }}>This idea is not here.</h2>
<p className="lede" style={{ marginTop: 10 }}>It may have been deleted, or hidden by the board's team.</p>
</div>
);
const target = post.merged ? posts.find((p) => p.id === post.merged) : undefined;
const ids = parseIds(post.tags);
const update = async (next: Record<string, unknown>, done: string) => {
try { await set(`boards/${boardId}/posts/${postId}`, next); toast(done); } catch (e) { toast(explain(e)); }
};
const remove = async () => {
if (!confirm(`Delete "${post.title}"? Its votes and comments go with it. This cannot be undone.`)) return;
try {
await set(`boards/${boardId}/posts/${postId}`, null);
toast("Deleted");
navigate(boardPath(boardId));
} catch (e) { toast(explain(e)); }
};
return (
<article className="post-page">
{back}
<div className="post-head">
<VoteButton post={post} size="lg" />
<div className="post-head-text">
<h2 className="post-title">{post.title}</h2>
<p className="post-row-meta">
<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>)}
<span className="muted">{post.name || "Guest"}, {ago(post.createdAt)}</span>
</p>
</div>
</div>
{post.merged && (
<p className="notice">
{target
? <>Merged into <a href={boardPath(boardId, `/p/${target.id}`)} data-nav>{target.title}</a>. Its votes moved there, so vote and follow it there.</>
: "Merged into another idea, and its votes moved there."}
</p>
)}
{post.hidden && !owner && post.by === user.id && <p className="notice">The board's team hid this idea, so only you and they can see it.</p>}
{shipped.map((e) => (
<p key={e.id} className="notice notice-ok shipped">
<strong>Shipped</strong> on {date(e.at)}: <a href={boardPath(boardId, `/changelog#${e.id}`)} data-nav>{e.title}</a>
</p>
))}
{post.details ? <Rich text={post.details} className="post-details" /> : <p className="muted post-details">No details.</p>}
{mergedIn.length > 0 && (
<p className="merged-in muted">
Merged in: {mergedIn.map((p, i) => <span key={p.id}>{i > 0 && ", "}<a href={boardPath(boardId, `/p/${p.id}`)} data-nav>{p.title}</a></span>)}
</p>
)}
{owner && (
<section className="owner-bar" aria-label="Manage this idea">
<label className="owner-status">
<span>Status</span>
<select className="select select-inline" value={post.status}
onChange={(e) => update({ status: e.target.value, statusAt: serverTimestamp() }, `Marked ${STATUS_LABEL[e.target.value as Status]}`)}>
{STATUSES.map((s) => <option key={s} value={s}>{STATUS_LABEL[s]}</option>)}
</select>
</label>
<span className="row owner-actions">
<button className="btn btn-sm btn-line" type="button" onClick={() => update({ pinned: !post.pinned }, post.pinned ? "Unpinned" : "Pinned to the top")}>{post.pinned ? "Unpin" : "Pin"}</button>
<button className="btn btn-sm btn-line" type="button" onClick={() => update({ hidden: !post.hidden }, post.hidden ? "Visible to everyone again" : "Hidden from everyone but the team and its author")}>{post.hidden ? "Unhide" : "Hide"}</button>
{!post.merged && <button className="btn btn-sm btn-line" type="button" onClick={() => setSheet("merge")}>Merge</button>}
<button className="btn btn-sm btn-line" type="button" onClick={() => setSheet("edit")}>Edit</button>
<button className="btn btn-sm btn-quiet btn-danger" type="button" onClick={remove}>Delete</button>
</span>
</section>
)}
<Comments postId={postId} comments={comments} />
{sheet === "merge" && <MergeSheet source={post} onClose={() => setSheet(null)} />}
{sheet === "edit" && <EditPostSheet post={post} onClose={() => setSheet(null)} />}
</article>
);
}
function Comments({ postId, comments }: { postId: string; comments: Comment[] | undefined }) {
const { boardId, board, user, owner } = useBoard();
const [body, setBody] = useState("");
const [name, setName] = useState(savedName);
const [team, setTeam] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const list = useMemo(() => [...(comments || [])].sort((a, b) => a.at - b.at), [comments]);
const [confirmDelete, setConfirmDelete] = useState<Comment | null>(null);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
const b = body.trim();
if (!b) { setError("Write a comment first."); return; }
setBusy(true);
setError(null);
const id = randomId(12);
try {
await setMany([
{ path: `boards/${boardId}/posts/${postId}/comments/${id}`, document: { body: b, name: name.trim(), team: owner && team, by: user.id, at: serverTimestamp() } },
{ path: `boards/${boardId}/posts/${postId}`, document: { comments: increment(1), lastComment: id } },
]);
saveName(name);
setBody("");
} catch (err) {
setError(explain(err));
} finally {
setBusy(false);
}
};
const remove = async (c: Comment) => {
try {
await setMany([
{ path: `boards/${boardId}/posts/${postId}/comments/${c.id}`, document: null },
{ path: `boards/${boardId}/posts/${postId}`, document: { comments: increment(-1) } },
]);
toast("Comment deleted");
} catch (err) { toast(explain(err)); }
setConfirmDelete(null);
};
return (
<section className="comments" aria-label="Comments">
<h3 className="h3">{comments ? plural(list.length, "comment") : "Comments"}</h3>
{comments === undefined ? <div className="loading"><span className="spinner" /> Loading comments...</div> : (
<ol className="comment-list">
{list.map((c) => (
<li key={c.id} className={`comment${c.team ? " comment-team" : ""}`}>
<p className="comment-meta">
<strong>{c.name || (c.team ? board.name : "Guest")}</strong>
{c.team && <span className="chip chip-cobalt">Team</span>}
<span className="muted">{ago(c.at)}</span>
{owner && <button className="link-btn comment-delete" type="button" onClick={() => setConfirmDelete(c)}>Delete</button>}
</p>
<Rich text={c.body} className="comment-body" />
</li>
))}
</ol>
)}
<form className="comment-form" onSubmit={submit}>
<label className="field">
<span className="sr-only">Comment</span>
<textarea className="textarea" rows={3} value={body} maxLength={LIMITS.comment} placeholder="Add a comment" onChange={(e) => { setBody(e.target.value); setError(null); }} />
</label>
<div className="comment-form-row">
<label className="field comment-name">
<span className="sr-only">Your name</span>
<input className="input" value={name} maxLength={LIMITS.name} placeholder="Your name (optional)" onChange={(e) => setName(e.target.value)} />
</label>
{owner && <label className="check check-sm"><input type="checkbox" checked={team} onChange={(e) => setTeam(e.target.checked)} /> Reply as the team</label>}
<button className="btn" type="submit" disabled={busy}>{busy ? "Posting..." : "Comment"}</button>
</div>
{error && <p className="notice notice-error" role="alert">{error}</p>}
</form>
{confirmDelete && (
<Modal title="Delete this comment?" onClose={() => setConfirmDelete(null)} footer={
<>
<button className="btn btn-line" type="button" onClick={() => setConfirmDelete(null)}>Keep it</button>
<button className="btn" type="button" onClick={() => remove(confirmDelete)}>Delete</button>
</>
}>
<p className="muted">"{confirmDelete.body.slice(0, 160)}{confirmDelete.body.length > 160 ? "..." : ""}" will be removed for everyone.</p>
</Modal>
)}
</section>
);
}
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