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