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