home

tcmts.examples.userinput.home · #5iovsgit · tsx file

// tcm: namespace userinput.pages
// The home page (the spaces you've been to and the ones you made, or, signed out, what userinput is), and making
// a space.
import { useState } from "react";
import { navigate, useData } from "#codebase/userinput/lib/react";
import { discussionCount, getRecords, imageUrl, loadOwnSpaces, loadVisited, parseAtUri } from "#codebase/userinput";
import type { SpaceRecord, SpaceTag, Stored } from "#codebase/userinput";
import { saveSpace, useMe, useSignedIn } from "#codebase/userinput/account";
import { Button, Chips, Empty, Field, Icon, IconButton, ImageChooser, Link, plainText, refreshSoon, Skeleton, spacePath, Title, toast, useAction } from "#codebase/userinput/design";

/**
 * Spaces anyone can look at first: userinput.app's own feedback board, then the busiest others (by discussions, as of
 * October 2026; of the 172 spaces then, found through the relay's `com.atproto.sync.listReposByCollection`).
 */
export const FEATURED = [
  "at://did:plc:uyixj57k6nmxrdj7pjs2ss5s/app.userinput.space/3mnovpobnnm2o", // userinput.app feedback
  "at://did:plc:ooensn4mr5mhznzypvxelfa3/app.userinput.space/3mnwmwn7sn22u", // mu feedback
  "at://did:plc:jaur46k6ijyfvl4lojza7eic/app.userinput.space/3mob6zfay3e2d", // Currents
  "at://did:plc:f4os2wz5fjl56xpwcvtnqu7m/app.userinput.space/3mprrc56lgd2e", // Standard Reader
  "at://did:plc:lmmx63zcns6gewgxqfdt4kof/app.userinput.space/3mpr5izppvt2k", // CraftSky
  "at://did:plc:revjuqmkvrw6fnkxppqtszpv/app.userinput.space/3mnqprje6we2o", // pckt.blog feedback
  "at://did:plc:mprdjqjluoswa7awzggaggj3/app.userinput.space/3msnhieoy7y2n", // Colibri Features
  "at://did:plc:ra4jsemddo2ii4pn5jaf6x4v/app.userinput.space/3mobgsd6d5n27", // Skyreader feedback
  "at://did:plc:lcieujcfkv4jx7gehsvok3pr/app.userinput.space/3mnzoxhxj5x2m", // Anisota
  "at://did:plc:nstib2qjcu3tyun7cxz4dxcm/app.userinput.space/3mpvl535uif2s", // StudyNext Feedback
  "at://did:plc:2f2ahswozqy4v5lvu676375y/app.userinput.space/3mrg7c2q5wn2l", // Sifa ID
  "at://did:plc:23cnpffmuf4vkpsnwhgyvljw/app.userinput.space/3mrrtpve7ab2f", // Northsky Social App
];

/** A space's icon: its picture, or its name's first letter. */
export function SpaceIcon({ space, large }: { space: Stored<SpaceRecord>; large?: boolean }) {
  const did = parseAtUri(space.uri)?.did ?? "";
  const icon = space.value.icon?.ref.$link;
  return (
    <span className={`space-icon sq ${large ? "large" : ""}`}>
      {icon ? <img src={imageUrl(did, icon, "icon")} alt="" /> : (space.value.name.trim()[0] ?? "").toUpperCase()}
    </span>
  );
}

/** How many discussions a space has, once counted. */
export function DiscussionCount({ uri }: { uri: string }) {
  const n = useData(`count:${uri}`, () => discussionCount(uri)).value;
  return <span className="caption">{n === undefined ? " " : `${n} ${n === 1 ? "discussion" : "discussions"}`}</span>;
}

/** A space, as a card linking to its board. */
export function SpaceCard({ space }: { space: Stored<SpaceRecord> }) {
  return (
    <Link to={spacePath(space.uri)} className="card sq space-card">
      <SpaceIcon space={space} />
      <div className="stack" style={{ gap: 6 }}>
        <div className="headline">{space.value.name}</div>
        {space.value.description ? <div className="row-excerpt">{plainText(space.value.description)}</div> : null}
        <DiscussionCount uri={space.uri} />
      </div>
    </Link>
  );
}

/** Spaces as cards, or skeletons while they're read. */
export function SpaceGrid({ spaces }: { spaces?: Stored<SpaceRecord>[] }) {
  if (!spaces)
    return (
      <div className="grid">
        {[0, 1, 2].map((i) => <div key={i} className="card sq stack"><Skeleton width={56} height={56} /><Skeleton width="50%" height={22} /><Skeleton /></div>)}
      </div>
    );
  return <div className="grid">{spaces.map((s) => <SpaceCard key={s.uri} space={s} />)}</div>;
}

/** What userinput is, for someone who isn't signed in. */
export function Welcome({ onSignIn }: { onSignIn: () => void }) {
  return (
    <section className="hero">
      <h1 className="large-title">Feedback, out in the open.</h1>
      <p className="subhead" style={{ maxWidth: 640 }}>
        Post ideas, vote on what matters, and watch them go from planned to shipped. Every post lives in its author's own AT Protocol repository.
      </p>
      <div className="inline">
        <Button variant="primary" size="large" onPress={onSignIn}>Sign in</Button>
        <Button variant="plain" size="large" onPress={() => navigate(spacePath(FEATURED[0]!))}>Look around</Button>
      </div>
    </section>
  );
}

/** The spaces worth showing everyone. */
export function Featured() {
  const spaces = useData("featured", () => {
    // each card's count, asked for with the spaces rather than once their cards are here (`once` keeps it for them)
    for (const uri of FEATURED) discussionCount(uri).catch(() => {});
    return getRecords<SpaceRecord>(FEATURED);
  });
  return (
    <section className="section">
      <div className="section-head"><h2 className="title">Featured</h2></div>
      <SpaceGrid spaces={spaces.value} />
    </section>
  );
}

/** The spaces you've been to, latest first. */
export function Visited({ me }: { me: string }) {
  const visited = useData(`visited:${me}`, () => loadVisited(me));
  if (visited.value && !visited.value.length) return null;
  return (
    <section className="section">
      <div className="section-head"><h2 className="title">Recently visited</h2></div>
      <SpaceGrid spaces={visited.value?.map((v) => v.space)} />
    </section>
  );
}

/** The spaces you made, and a way to make another. */
export function YourSpaces({ me }: { me: string }) {
  const spaces = useData(`spaces:${me}`, () => loadOwnSpaces(me));
  return (
    <section className="section">
      <div className="section-head">
        <h2 className="title">Your spaces</h2>
        <Button variant="plain" icon="plus" onPress={() => navigate("/new")}>New space</Button>
      </div>
      {spaces.value && !spaces.value.length ? (
        <div className="card sq">
          <Empty icon="space" title="Collect feedback for your project">
            <span>A space is a board people post ideas and bugs to, with the tags you choose.</span>
            <Button variant="primary" onPress={() => navigate("/new")}>Make a space</Button>
          </Empty>
        </div>
      ) : (
        <SpaceGrid spaces={spaces.value} />
      )}
    </section>
  );
}

/** The home page. */
export function HomePage({ onSignIn }: { onSignIn: () => void }) {
  const signed = useSignedIn();
  return (
    <main className="page">
      <Title />
      {signed.state === "in" ? (
        <>
          <h1 className="large-title">Spaces</h1>
          <YourSpaces me={signed.session.did} />
          <Visited me={signed.session.did} />
        </>
      ) : (
        <Welcome onSignIn={onSignIn} />
      )}
      <Featured />
    </main>
  );
}

/** A tag's stable value, from its label: lower case, dashes for spaces. */
export const tagValue = (label: string): string => label.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 64);

/** A space's tags: a chip each, removable, and a field to add one. */
export function TagsEditor({ tags, onChange }: { tags: SpaceTag[]; onChange: (t: SpaceTag[]) => void }) {
  const [label, setLabel] = useState("");
  const add = () => {
    const value = tagValue(label);
    if (!value || tags.some((t) => t.value === value) || tags.length >= 24) return;
    onChange([...tags, { label: label.trim(), value }]);
    setLabel("");
  };
  return (
    <div className="field">
      <span className="field-label">Tags</span>
      <Chips label="Tags" options={tags} removable={(v) => onChange(tags.filter((t) => t.value !== v))} />
      <form className="inline" onSubmit={(e) => (e.preventDefault(), add())}>
        <div style={{ flex: 1 }}><Field label="New tag" hideLabel value={label} placeholder="Bug, Feature, Question…" onChange={setLabel} maxLength={64} /></div>
        <IconButton icon="plus" label="Add tag" onPress={add} />
      </form>
    </div>
  );
}

/** An image chosen for a space's icon, shown before it's uploaded. */
export function IconPicker({ file, onChange }: { file?: Blob; onChange: (f: Blob | undefined) => void }) {
  const [url, setUrl] = useState<string>();
  return (
    <div className="field">
      <span className="field-label">Icon</span>
      <div className="inline">
        <span className="space-icon sq large">{url ? <img src={url} alt="The new icon" /> : <Icon name="photo" size={32} />}</span>
        <ImageChooser label={file ? "Change" : "Choose an image"} onChoose={([f]) => {
          if (f && f.size > 1_000_000) return toast("Icons can be up to 1 MB");
          onChange(f);
          setUrl(f ? URL.createObjectURL(f) : undefined);
        }} />
      </div>
    </div>
  );
}

/** Making a space: its name, what it's for, its tags and its icon. */
export function NewSpacePage({ onSignIn }: { onSignIn: () => void }) {
  const signed = useSignedIn();
  const me = useMe();
  const [name, setName] = useState("");
  const [description, setDescription] = useState("");
  const [tags, setTags] = useState<SpaceTag[]>([
    { label: "Bug", value: "bug" },
    { label: "Feature", value: "feature" },
    { label: "Question", value: "question" },
  ]);
  const [icon, setIcon] = useState<Blob>();
  const [busy, run] = useAction();
  if (!me)
    return (
      <main className="page narrow">
        <Empty icon="space" title="Sign in to make a space"><Button variant="primary" onPress={onSignIn}>Sign in</Button></Empty>
      </main>
    );
  return (
    <main className="page narrow">
      <Title text="New space" />
      <h1 className="large-title">New space</h1>
      <p className="subhead" style={{ marginBottom: 36 }}>A board for feedback on your project. It lives in your repository; you moderate it.</p>
      <div className="card sq">
        <Field label="Name" value={name} onChange={setName} placeholder="My app" maxLength={100} big />
        <Field label="What it's for" value={description} onChange={setDescription} placeholder="Tell us what you'd change." multiline maxLength={1500} />
        <TagsEditor tags={tags} onChange={setTags} />
        <IconPicker file={icon} onChange={setIcon} />
        <div className="sheet-actions">
          <Button variant="primary" size="large" busy={busy} disabled={!name.trim()} onPress={() => void run(async () => {
            if (signed.state !== "in") return;
            const made = await saveSpace(signed.session, { name, description, tags, icon });
            toast("Space made");
            refreshSoon();
            navigate(spacePath(made.uri));
          })}>Make space</Button>
        </div>
      </div>
    </main>
  );
}

/** A page that isn't there. */
export function NotFoundPage() {
  return (
    <main className="page narrow">
      <Title text="Not found" />
      <Empty icon="search" title="Nothing's here">
        <Button variant="primary" onPress={() => navigate("/")}>Go home</Button>
      </Empty>
    </main>
  );
}

used by