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