feedback-free.openapps.xyz

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.
Owned app · rev 4a4c54cd95c6
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.
← All files
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
4a4c54cd95 · 18 hours ago
Release with preview
1d0728818d · 19 hours ago
Retry release
1abc843b3f · 19 hours ago
Bind app agent address in policy
00eb15f50d · 19 hours ago
Use OpenApps service URLs and add contribution strip
347a7b8ca0 · 19 hours ago
Bounded source sync
a0aaceae63 · 1 day ago
Bounded source sync
3125b8113f · 1 day ago
Bounded source sync
882a76ba95 · 1 day ago
Bounded source sync
852d9782ee · 1 day ago
Bounded source sync
a8ee705109 · 1 day ago