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/Board.tsx
// A board: opens it on this device (the link is the permission; an admin link also makes you an owner),
// then shows the ideas, the roadmap, the changelog or one post. The embed view is the same board, compact.
import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
import { get, set, setMany, useQuery, serverTimestamp } from "@bounded-sh/client";
import { me, explain, takeLoginError, signOut, type Me } from "./lib/bounded";
import { navigate } from "./lib/router";
import { adminKey, rememberBoard, rememberVisit } from "./lib/mine";
import { restoreCarriedVotes, signInKeepingVotes } from "./lib/votes";
import { toast, share, copy } from "./ui";
import { Ideas } from "./Ideas";
import { PostPage } from "./Post";
import { Roadmap } from "./Roadmap";
import { Changelog } from "./Changelog";
import { SettingsSheet, EmbedSheet } from "./Admin";
import { SITE_URL } from "../functions/lib/config";
import { parseTags, listed, type Board, type Entry, type Post, type Tag } from "../functions/lib/feedback";
export type View = "ideas" | "roadmap" | "changelog" | "post" | "embed";
export interface BoardCtx {
boardId: string;
user: Me;
owner: boolean;
board: Board;
tags: Tag[];
/** Every post this person can read, merged ones included (lists drop them). */
posts: Post[];
voted: Set<string>;
entries: Entry[];
}
const Ctx = createContext<BoardCtx | null>(null);
export const useBoard = () => useContext(Ctx)!;
export const boardPath = (boardId: string, rest = "") => `/b/${boardId}${rest}`;
const ALL = { limit: 1000 };
export function BoardPage({ boardId, view, postId, claimKey }: { boardId: string; view: View; postId?: string; claimKey?: string | null }) {
const [state, setState] = useState<"joining" | "ready" | "missing" | "error">("joining");
const [user, setUser] = useState<Me | null>(null);
const [owner, setOwner] = useState(false);
const [claim, setClaim] = useState<"ok" | "bad" | null>(null);
const [error, setError] = useState("");
useEffect(() => {
let cancelled = false;
(async () => {
try {
const m = await me();
if (!(await get(`boards/${boardId}/joins/${m.id}`))) {
try {
await set(`boards/${boardId}/joins/${m.id}`, { at: serverTimestamp() });
} catch {
if (!cancelled) setState("missing");
return;
}
}
let isOwner = Boolean(await get(`boards/${boardId}/owners/${m.id}`));
if (claimKey) {
if (!isOwner) {
try {
await setMany([
{ path: `boardclaims/${boardId}/c/${m.id}`, document: { key: claimKey } },
{ path: `boards/${boardId}/owners/${m.id}`, document: { at: serverTimestamp() } },
]);
isOwner = true;
} catch {
if (!cancelled) setClaim("bad");
}
}
if (isOwner) {
rememberBoard({ id: boardId, key: claimKey });
if (!cancelled) setClaim("ok");
}
}
rememberVisit(boardId);
const loginError = takeLoginError();
if (loginError) toast(`Sign-in did not finish: ${loginError}`);
restoreCarriedVotes().then((n) => { if (n) toast(m.guest ? "Your votes are back." : `Signed in. ${n === 1 ? "Your vote" : `Your ${n} votes`} came with you.`); });
if (!cancelled) { setUser(m); setOwner(isOwner); setState("ready"); }
} catch (err) {
if (!cancelled) { setError(explain(err)); setState("error"); }
}
})();
return () => { cancelled = true; };
}, [boardId]); // eslint-disable-line react-hooks/exhaustive-deps
if (state === "joining") return <div className="wrap page-pad"><div className="loading"><span className="spinner" /> Opening the board...</div></div>;
if (state === "missing") return (
<div className="wrap page-pad narrow">
<h1 className="h1">This board does not exist.</h1>
<p className="lede" style={{ marginTop: 14 }}>Check the link you were sent. Board links are long and every character counts.</p>
<a className="btn" style={{ marginTop: 24 }} href="/" data-nav>Make a free board</a>
</div>
);
if (state === "error" || !user) return <div className="wrap page-pad narrow"><p className="notice notice-error">{error}</p></div>;
return <BoardData boardId={boardId} user={user} owner={owner} claim={claim} view={view} postId={postId} />;
}
function BoardData({ boardId, user, owner, claim, view, postId }: { boardId: string; user: Me; owner: boolean; claim: "ok" | "bad" | null; view: View; postId?: string }) {
const { data: board } = useQuery<Board | null>(`boards/${boardId}`);
const { data: posts } = useQuery<Post[]>(`boards/${boardId}/posts`, ALL);
const { data: votes } = useQuery<{ id: string }[]>(`boards/${boardId}/people/${user.id}/votes`, ALL);
const { data: entries } = useQuery<Entry[]>(`boards/${boardId}/changelog`, ALL);
const ctx = useMemo<BoardCtx | null>(() => board && posts && votes && entries ? {
boardId, user, owner, board,
tags: parseTags(board.tags),
posts,
voted: new Set(votes.map((v) => v.id)),
entries: [...entries].sort((a, b) => b.at - a.at),
} : null, [boardId, user, owner, board, posts, votes, entries]);
useEffect(() => {
if (!board?.name) return;
document.title = `${board.name} | Feedback`;
if (owner) rememberBoard({ id: boardId, title: board.name });
}, [board?.name, owner, boardId]);
if (board === null) return <div className="wrap page-pad narrow"><p className="notice notice-error">This board could not be loaded.</p></div>;
if (!ctx) return <div className="wrap page-pad"><div className="loading"><span className="spinner" /> Loading...</div></div>;
return (
<Ctx.Provider value={ctx}>
{view === "embed" ? <EmbedView /> : <BoardShell view={view} postId={postId} claim={claim} />}
</Ctx.Provider>
);
}
const TABS: { view: View; label: string; rest: string }[] = [
{ view: "ideas", label: "Ideas", rest: "" },
{ view: "roadmap", label: "Roadmap", rest: "/roadmap" },
{ view: "changelog", label: "Changelog", rest: "/changelog" },
];
function BoardShell({ view, postId, claim }: { view: View; postId?: string; claim: "ok" | "bad" | null }) {
const { boardId, board, owner, posts, entries } = useBoard();
const [sheet, setSheet] = useState<"settings" | "embed" | null>(null);
const [isNew, setIsNew] = useState(() => localStorage.getItem(`feedback:new:${boardId}`) === "1");
const url = `${SITE_URL}${boardPath(boardId)}`;
const key = adminKey(boardId);
const adminUrl = key ? `${url}/admin#k=${key}` : null;
const count = listed(posts).filter((p) => !p.hidden).length;
const active = view === "post" ? "ideas" : view;
return (
<div className="board">
<header className="board-head">
<div className="wrap board-head-inner">
<div className="board-title-block">
<p className="eyebrow">Feedback board</p>
<h1 className="board-title">{board.name}</h1>
{board.intro && <p className="board-intro">{board.intro}</p>}
</div>
<div className="row board-actions">
<button className="btn btn-line btn-sm" type="button" onClick={() => share(url, board.name)}>
<LinkIcon /> Share
</button>
{owner && <button className="btn btn-line btn-sm" type="button" onClick={() => setSheet("embed")}>Add to your site</button>}
{owner && <button className="btn btn-line btn-sm" type="button" onClick={() => setSheet("settings")}>Settings</button>}
</div>
</div>
<nav className="wrap tabs" aria-label="Board">
{TABS.map((t) => (
<a key={t.view} className={`tab${active === t.view ? " on" : ""}`} href={boardPath(boardId, t.rest)} data-nav aria-current={active === t.view ? "page" : undefined}>
{t.label}
{t.view === "ideas" && <span className="tab-count">{count}</span>}
{t.view === "changelog" && entries.length > 0 && <span className="tab-count">{entries.length}</span>}
</a>
))}
</nav>
</header>
<div className="wrap board-body">
{isNew && owner && (
<div className="notice notice-ok invite">
<div className="invite-text">
<strong>Your board is live.</strong> Share the link with your users, or add it to your website. Keep your admin link: it lets you manage the board from any device.
</div>
<span className="row invite-actions">
<button className="btn btn-sm" type="button" onClick={() => share(url, board.name)}>Share link</button>
{adminUrl && <button className="btn btn-sm btn-line" type="button" onClick={() => copy(adminUrl, "Admin link copied. Keep it private.")}>Copy admin link</button>}
</span>
<button className="icon-btn invite-close" type="button" aria-label="Dismiss" onClick={() => { localStorage.removeItem(`feedback:new:${boardId}`); setIsNew(false); }}>
<CloseIcon />
</button>
</div>
)}
{claim === "ok" && <p className="notice notice-ok">You can manage this board on this device now.</p>}
{claim === "bad" && <p className="notice notice-error">That admin link did not work. Check that you copied all of it. You can still post and vote.</p>}
{view === "ideas" && <Ideas />}
{view === "post" && postId && <PostPage key={postId} postId={postId} />}
{view === "roadmap" && <Roadmap />}
{view === "changelog" && <Changelog />}
</div>
{sheet === "settings" && <SettingsSheet adminUrl={adminUrl} onClose={() => setSheet(null)} />}
{sheet === "embed" && <EmbedSheet onClose={() => setSheet(null)} />}
</div>
);
}
/** The compact board a website opens in a small window: post an idea, vote, see statuses. */
function EmbedView() {
const { boardId, board } = useBoard();
useEffect(() => {
document.body.classList.add("embed-mode");
return () => document.body.classList.remove("embed-mode");
}, []);
return (
<div className="embed">
<header className="embed-head">
<h1 className="embed-title">{board.name}</h1>
<a className="embed-open" href={boardPath(boardId)} target="_blank" rel="noopener">Open the full board</a>
</header>
<Ideas compact />
<p className="embed-foot">Free feedback boards by <a href="/" target="_blank" rel="noopener">Feedback</a> from OpenApps</p>
</div>
);
}
/** Who you are on this board, and the optional email sign-in that keeps your votes on every device. */
export function AccountNote() {
const { user } = useBoard();
const [busy, setBusy] = useState(false);
if (!user.guest) return (
<div className="account">
<p>Signed in as <strong>{user.email || "your account"}</strong>. Your votes follow you to every device.</p>
<button className="link-btn" type="button" onClick={() => signOut()}>Sign out</button>
</div>
);
return (
<div className="account">
<p>You vote as a guest on this browser: one vote per idea, no account needed.</p>
<button className="link-btn" type="button" disabled={busy} onClick={async () => {
setBusy(true);
try { await signInKeepingVotes(); } catch (e) { toast(explain(e)); setBusy(false); }
}}>{busy ? "Taking your votes along..." : "Sign in with email to keep your votes on every device"}</button>
</div>
);
}
export function Empty({ title, children }: { title: string; children?: ReactNode }) {
return (
<div className="empty">
<img className="art" src="/art/card-feedback.webp" alt="" width="160" height="140" />
<p className="h3">{title}</p>
{children}
</div>
);
}
export const LinkIcon = () => (
<svg width="16" height="16" viewBox="0 0 20 20" aria-hidden="true"><path d="M8 12l4-4M7 9.5 5.2 11.3a2.8 2.8 0 1 0 4 4L11 13.5M13 10.5l1.8-1.8a2.8 2.8 0 1 0-4-4L9 6.5" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" /></svg>
);
export const CloseIcon = () => (
<svg width="16" height="16" viewBox="0 0 20 20" aria-hidden="true"><path d="M5 5l10 10M15 5 5 15" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" /></svg>
);
export function goPost(boardId: string, postId: string) {
navigate(boardPath(boardId, `/p/${postId}`));
}
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