Add a dedicated Create Community page
CommunitiesPage used to open CreateCommunityDialog when the user hit
'Create community' in the hero, the My Communities shelf, or the empty
state. Replace those entry points with navigate('/communities/new'),
mirroring how CreateCampaignPage and CreateActionPage already work.
The new page covers the same three NIP-72 fields the dialog handled
(name + description + cover image) plus a Moderators section that
the dialog never exposed:
- Name, with a live URL preview of the derived slug for transparency.
- Description, in the same Textarea shape as the dialog.
- Cover image, via the shared <CoverImageField> so drag-and-drop +
paste-URL behavior matches the campaign and action create pages.
- Moderators, via the same <PersonSearch> CreateCampaignPage uses for
recipients. The founder is pinned at the top of the list as a
non-removable row labeled 'Founder'; PersonSearch is told to exclude
the founder so they can't be added a second time. Extra moderators
go into the kind 34550 event as additional ['p', pk, '', 'moderator']
tags alongside the founder's.
Submit logic is the kind 30009 badge mint + kind 34550 community
publish from CreateCommunityDialog's create branch, including the
d-tag and badge-d-tag collision checks. Cache keys touched on success
match the dialog: ['addr-event', 34550, pubkey, dTag] is seeded,
['my-communities'] and ['community-activity-feed'] are invalidated.
CreateCommunityDialog itself stays in the tree because
CommunityDetailPage still opens it in edit mode. A unified edit page
that folds in 'View members', 'Add members', and 'Edit badge' is
explicitly out of scope for this change.
This commit is contained in:
@@ -44,6 +44,7 @@ const BookmarksPage = lazy(() => import("./pages/BookmarksPage").then(m => ({ de
|
||||
const BooksPage = lazy(() => import("./pages/BooksPage").then(m => ({ default: m.BooksPage })));
|
||||
const ChangelogPage = lazy(() => import("./pages/ChangelogPage").then(m => ({ default: m.ChangelogPage })));
|
||||
const CommunitiesPage = lazy(() => import("./pages/CommunitiesPage").then(m => ({ default: m.CommunitiesPage })));
|
||||
const CreateCommunityPage = lazy(() => import("./pages/CreateCommunityPage").then(m => ({ default: m.CreateCommunityPage })));
|
||||
const ContentPage = lazy(() => import("./pages/ContentPage").then(m => ({ default: m.ContentPage })));
|
||||
const ContentSettingsPage = lazy(() => import("./pages/ContentSettingsPage").then(m => ({ default: m.ContentSettingsPage })));
|
||||
const CSAEPolicyPage = lazy(() => import("./pages/CSAEPolicyPage").then(m => ({ default: m.CSAEPolicyPage })));
|
||||
@@ -291,6 +292,7 @@ export function AppRouter() {
|
||||
<Route path="/bluesky" element={<BlueskyPage />} />
|
||||
<Route path="/wikipedia" element={<WikipediaPage />} />
|
||||
<Route path="/communities" element={<CommunitiesPage />} />
|
||||
<Route path="/communities/new" element={<CreateCommunityPage />} />
|
||||
<Route path="/letters" element={<LettersPage />} />
|
||||
<Route path="/letters/compose" element={<LetterComposePage />} />
|
||||
<Route path="/settings/letters" element={<LetterPreferencesPage />} />
|
||||
|
||||
@@ -6,7 +6,6 @@ import { Globe2, HandHeart, Loader2, PlusCircle, Search, Users } from 'lucide-re
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import { useInView } from 'react-intersection-observer';
|
||||
|
||||
import { CreateCommunityDialog } from '@/components/CreateCommunityDialog';
|
||||
import { FeedEmptyState } from '@/components/FeedEmptyState';
|
||||
import { HeroAtmosphere } from '@/components/HeroAtmosphere';
|
||||
import { HeroBanner } from '@/components/HeroBanner';
|
||||
@@ -71,7 +70,6 @@ export function CommunitiesPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||
|
||||
useLayoutOptions({
|
||||
noMaxWidth: true,
|
||||
@@ -92,7 +90,7 @@ export function CommunitiesPage() {
|
||||
});
|
||||
return;
|
||||
}
|
||||
setCreateDialogOpen(true);
|
||||
navigate('/communities/new');
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -140,8 +138,6 @@ export function CommunitiesPage() {
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<CreateCommunityDialog open={createDialogOpen} onOpenChange={setCreateDialogOpen} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,448 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useSeoMeta } from '@unhead/react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowLeft,
|
||||
Loader2,
|
||||
Users,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { PersonSearch } from '@/components/AddMemberDialog';
|
||||
import { CoverImageField } from '@/components/CoverImageField';
|
||||
import { FormSection } from '@/components/FormSection';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import type { SearchProfile } from '@/hooks/useSearchProfiles';
|
||||
import { useLayoutOptions } from '@/contexts/LayoutContext';
|
||||
import {
|
||||
BADGE_DEFINITION_KIND,
|
||||
COMMUNITY_DEFINITION_KIND,
|
||||
} from '@/lib/communityUtils';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
|
||||
/**
|
||||
* Convert text into a URL-safe slug for the NIP-72 community's d-tag.
|
||||
* Lifted verbatim from CreateCommunityDialog so the same name produces the
|
||||
* same identifier.
|
||||
*/
|
||||
function slugify(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^\w\s-]/g, '')
|
||||
.replace(/[\s_]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a minimal SearchProfile shell when we only know a pubkey. PersonSearch
|
||||
* hands us full profiles, but anywhere we synthesize a pending moderator
|
||||
* row (e.g. the founder pinned at the top of the list) we need this stub.
|
||||
*/
|
||||
function makeProfileFromPubkey(pubkey: string): SearchProfile {
|
||||
return {
|
||||
pubkey,
|
||||
metadata: {},
|
||||
event: {
|
||||
id: '',
|
||||
pubkey,
|
||||
created_at: 0,
|
||||
kind: 0,
|
||||
tags: [],
|
||||
content: '{}',
|
||||
sig: '',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function CreateCommunityPage() {
|
||||
useLayoutOptions({ noMaxWidth: true, rightSidebar: null });
|
||||
|
||||
const { user } = useCurrentUser();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { nostr } = useNostr();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [imageUrl, setImageUrl] = useState('');
|
||||
// Additional moderators on top of the founder, who is always the first
|
||||
// moderator and rendered separately so they can't be removed.
|
||||
const [moderators, setModerators] = useState<SearchProfile[]>([]);
|
||||
const [formError, setFormError] = useState('');
|
||||
|
||||
const derivedSlug = useMemo(() => slugify(name), [name]);
|
||||
|
||||
useSeoMeta({
|
||||
title: 'Create community | Agora',
|
||||
description: 'Start a new community on Agora.',
|
||||
});
|
||||
|
||||
const addModerator = useCallback((profile: SearchProfile) => {
|
||||
setModerators((prev) =>
|
||||
prev.some((m) => m.pubkey === profile.pubkey) ? prev : [...prev, profile],
|
||||
);
|
||||
}, []);
|
||||
|
||||
const addModerators = useCallback((profiles: SearchProfile[]) => {
|
||||
setModerators((prev) => {
|
||||
const seen = new Set(prev.map((m) => m.pubkey));
|
||||
const next = [...prev];
|
||||
for (const profile of profiles) {
|
||||
if (seen.has(profile.pubkey)) continue;
|
||||
seen.add(profile.pubkey);
|
||||
next.push(profile);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const removeModerator = useCallback((pubkey: string) => {
|
||||
setModerators((prev) => prev.filter((m) => m.pubkey !== pubkey));
|
||||
}, []);
|
||||
|
||||
const submitMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!user) throw new Error('You must be logged in to create a community.');
|
||||
|
||||
const trimmedName = name.trim();
|
||||
if (!trimmedName) throw new Error('Name is required.');
|
||||
|
||||
const slug = derivedSlug;
|
||||
if (!slug) {
|
||||
throw new Error(
|
||||
'Name must include letters or numbers so a community URL can be created.',
|
||||
);
|
||||
}
|
||||
|
||||
// The founder is always pubkey #0 in the moderator list — they
|
||||
// shouldn't end up in the extra-moderators array, but defensively
|
||||
// strip if they did.
|
||||
const extraModerators = moderators.filter(
|
||||
(m) => m.pubkey !== user.pubkey,
|
||||
);
|
||||
|
||||
// d-tag collision check: don't silently overwrite an existing
|
||||
// community of yours with the same slug.
|
||||
const existing = await nostr.query([
|
||||
{
|
||||
kinds: [COMMUNITY_DEFINITION_KIND],
|
||||
authors: [user.pubkey],
|
||||
'#d': [slug],
|
||||
limit: 1,
|
||||
},
|
||||
]);
|
||||
if (existing.length > 0) {
|
||||
throw new Error(
|
||||
`You already have a community with the identifier "${slug}". Choose another name.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Same collision check for the implicitly-minted member badge.
|
||||
const badgeDTag = `${slug}-member`;
|
||||
const existingBadge = await nostr.query([
|
||||
{
|
||||
kinds: [BADGE_DEFINITION_KIND],
|
||||
authors: [user.pubkey],
|
||||
'#d': [badgeDTag],
|
||||
limit: 1,
|
||||
},
|
||||
]);
|
||||
if (existingBadge.length > 0) {
|
||||
throw new Error(
|
||||
'You already have a member badge with this identifier. Choose a different community name so the badge can be created safely.',
|
||||
);
|
||||
}
|
||||
|
||||
const sanitizedImage = imageUrl.trim()
|
||||
? sanitizeUrl(imageUrl.trim())
|
||||
: undefined;
|
||||
|
||||
// Mint the implicit "Member of <community>" badge (kind 30009).
|
||||
// Mirrors CreateCommunityDialog so existing badge-award flows on
|
||||
// CommunityDetailPage keep working.
|
||||
const badgeEvent: NostrEvent = await publishEvent({
|
||||
kind: BADGE_DEFINITION_KIND,
|
||||
content: '',
|
||||
tags: [
|
||||
['d', badgeDTag],
|
||||
['name', 'Member'],
|
||||
['description', `Member of ${trimmedName}`],
|
||||
['alt', `Badge definition: Member of ${trimmedName}`],
|
||||
],
|
||||
});
|
||||
|
||||
const badgeATag = `${BADGE_DEFINITION_KIND}:${badgeEvent.pubkey}:${badgeDTag}`;
|
||||
|
||||
// Build the kind 34550 community-definition tag set.
|
||||
const tags: string[][] = [
|
||||
['d', slug],
|
||||
['name', trimmedName],
|
||||
];
|
||||
if (description.trim()) {
|
||||
tags.push(['description', description.trim()]);
|
||||
}
|
||||
if (sanitizedImage) {
|
||||
tags.push(['image', sanitizedImage]);
|
||||
}
|
||||
// Member badge reference (NIP-72 style: `a` tag with role "member").
|
||||
tags.push(['a', badgeATag, '', 'member']);
|
||||
// Founder is the first moderator.
|
||||
tags.push(['p', user.pubkey, '', 'moderator']);
|
||||
for (const mod of extraModerators) {
|
||||
tags.push(['p', mod.pubkey, '', 'moderator']);
|
||||
}
|
||||
tags.push(['alt', `Community: ${trimmedName}`]);
|
||||
|
||||
const created = await publishEvent({
|
||||
kind: COMMUNITY_DEFINITION_KIND,
|
||||
content: '',
|
||||
tags,
|
||||
});
|
||||
|
||||
return { event: created, slug };
|
||||
},
|
||||
onSuccess: async ({ event, slug }) => {
|
||||
const naddr = nip19.naddrEncode({
|
||||
kind: COMMUNITY_DEFINITION_KIND,
|
||||
pubkey: event.pubkey,
|
||||
identifier: slug,
|
||||
});
|
||||
queryClient.setQueryData(
|
||||
['addr-event', COMMUNITY_DEFINITION_KIND, event.pubkey, slug],
|
||||
event,
|
||||
);
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ['my-communities'],
|
||||
exact: false,
|
||||
});
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ['community-activity-feed'],
|
||||
exact: false,
|
||||
});
|
||||
toast({ title: 'Community created!' });
|
||||
navigate(`/${naddr}`);
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
setFormError(msg);
|
||||
toast({
|
||||
title: 'Could not create community',
|
||||
description: msg,
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<main className="min-h-screen pb-16">
|
||||
<div className="max-w-2xl mx-auto px-4 sm:px-6 py-12">
|
||||
<Card>
|
||||
<CardContent className="py-12 px-8 text-center space-y-4">
|
||||
<Users className="size-10 text-muted-foreground/60 mx-auto" />
|
||||
<h2 className="text-xl font-semibold">Log in to start a community</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Communities are signed Nostr events. You need a Nostr login to publish one.
|
||||
</p>
|
||||
<Button asChild>
|
||||
<Link to="/communities">Back to communities</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
// Founder is always rendered first and isn't removable; that mirrors
|
||||
// CreateCommunityDialog (founder is auto-added as the only moderator)
|
||||
// while making the role visible to the user.
|
||||
const founderProfile = makeProfileFromPubkey(user.pubkey);
|
||||
|
||||
return (
|
||||
<main className="min-h-screen pb-16">
|
||||
<form
|
||||
className="max-w-3xl mx-auto px-4 sm:px-6 py-8 lg:py-10 space-y-5"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
setFormError('');
|
||||
submitMutation.mutate();
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div className="flex items-center gap-2 -ml-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(-1)}
|
||||
className="p-2 rounded-full hover:bg-secondary motion-safe:transition-colors text-muted-foreground hover:text-foreground"
|
||||
aria-label="Go back"
|
||||
>
|
||||
<ArrowLeft className="size-5" />
|
||||
</button>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold tracking-tight">
|
||||
Create community
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl bg-card/50 p-2">
|
||||
{/* Name */}
|
||||
<FormSection title="Name" requirement="Required">
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g. The Arbiter's Guard"
|
||||
maxLength={100}
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
URL preview:{' '}
|
||||
<span className="font-mono text-foreground">
|
||||
/{derivedSlug || 'your-community-name'}
|
||||
</span>
|
||||
</p>
|
||||
</FormSection>
|
||||
|
||||
{/* Description */}
|
||||
<FormSection title="Description" requirement="Recommended">
|
||||
<Textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="What is this community about?"
|
||||
rows={3}
|
||||
/>
|
||||
</FormSection>
|
||||
|
||||
{/* Cover image */}
|
||||
<FormSection title="Cover image" requirement="Recommended">
|
||||
<CoverImageField value={imageUrl} onChange={setImageUrl} />
|
||||
</FormSection>
|
||||
|
||||
{/* Moderators */}
|
||||
<FormSection title="Moderators" requirement="Optional">
|
||||
<div className="space-y-3">
|
||||
<PersonSearch
|
||||
onAdd={addModerator}
|
||||
onAddMany={addModerators}
|
||||
// The founder is always a moderator and not eligible to be
|
||||
// added a second time. Past that, exclude anyone already
|
||||
// queued.
|
||||
excludePubkeys={[user.pubkey, ...moderators.map((m) => m.pubkey)]}
|
||||
/>
|
||||
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Moderators ({moderators.length + 1})
|
||||
</Label>
|
||||
<div className="space-y-1.5">
|
||||
<ModeratorRow profile={founderProfile} role="Founder" />
|
||||
{moderators.map((moderator) => (
|
||||
<ModeratorRow
|
||||
key={moderator.pubkey}
|
||||
profile={moderator}
|
||||
role="Moderator"
|
||||
onRemove={() => removeModerator(moderator.pubkey)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</FormSection>
|
||||
</div>
|
||||
|
||||
{formError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="size-4" />
|
||||
<AlertDescription>{formError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="pt-1">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={submitMutation.isPending || !name.trim() || !derivedSlug}
|
||||
className="w-full"
|
||||
>
|
||||
{submitMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="size-4 mr-2 animate-spin" />
|
||||
Creating…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Users className="size-4 mr-2" />
|
||||
Create community
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Layout helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
function ModeratorRow({
|
||||
profile,
|
||||
role,
|
||||
onRemove,
|
||||
}: {
|
||||
profile: SearchProfile;
|
||||
role: 'Founder' | 'Moderator';
|
||||
/** Omit to render a non-removable row (e.g. the founder). */
|
||||
onRemove?: () => void;
|
||||
}) {
|
||||
const displayName =
|
||||
profile.metadata.display_name ||
|
||||
profile.metadata.name ||
|
||||
genUserName(profile.pubkey);
|
||||
const picture = sanitizeUrl(profile.metadata.picture);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg bg-secondary/30 p-2.5">
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="size-8 shrink-0">
|
||||
{picture && <AvatarImage src={picture} alt="" />}
|
||||
<AvatarFallback className="text-xs">
|
||||
{displayName.slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium">{displayName}</div>
|
||||
<div className="text-xs text-muted-foreground">{role}</div>
|
||||
</div>
|
||||
{onRemove && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onRemove}
|
||||
aria-label={`Remove ${displayName}`}
|
||||
className="shrink-0"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CreateCommunityPage;
|
||||
Reference in New Issue
Block a user