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/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
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