Add community event creation dialog
This commit is contained in:
@@ -1,34 +1,28 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import { CalendarDays, MapPin, Clock, Users } from 'lucide-react';
|
||||
import { CalendarDays, Clock, MapPin, Users } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { NoteContent } from '@/components/NoteContent';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { RSVPAvatars } from '@/components/RSVPAvatars';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface CalendarEventContentProps {
|
||||
event: NostrEvent;
|
||||
/** When true, limits the description to 2 lines for compact feed display. */
|
||||
/** When true, renders a compact feed card. */
|
||||
compact?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** Extract the first value for a given tag name. */
|
||||
function getTag(tags: string[][], name: string): string | undefined {
|
||||
return tags.find(([n]) => n === name)?.[1];
|
||||
}
|
||||
|
||||
/** Collect all values for a repeated tag name. */
|
||||
function getAllTags(tags: string[][], name: string): string[][] {
|
||||
return tags.filter(([n]) => n === name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a location tag value. Some clients encode location as JSON
|
||||
* (e.g. `{"description":"Riga, Latvia","coordinates":{"lat":56.9,"lon":24.1}}`).
|
||||
* Extract a human-readable string when possible, otherwise return the raw value.
|
||||
*/
|
||||
function parseLocation(raw: string): string {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed.startsWith('{')) return raw;
|
||||
@@ -43,14 +37,12 @@ function parseLocation(raw: string): string {
|
||||
return raw;
|
||||
}
|
||||
|
||||
/** Date-only formatter: "Jan 15, 2026" */
|
||||
const dateFormatter = new Intl.DateTimeFormat('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
|
||||
/** Date+time formatter: "Jan 15, 2026 at 3:00 PM" */
|
||||
const dateTimeFormatter = new Intl.DateTimeFormat('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
@@ -59,52 +51,35 @@ const dateTimeFormatter = new Intl.DateTimeFormat('en-US', {
|
||||
minute: '2-digit',
|
||||
});
|
||||
|
||||
/** Time-only formatter: "3:00 PM" */
|
||||
const timeFormatter = new Intl.DateTimeFormat('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
});
|
||||
|
||||
/** Check if two dates fall on the same calendar day. */
|
||||
function isSameDay(a: Date, b: Date): boolean {
|
||||
return (
|
||||
a.getFullYear() === b.getFullYear() &&
|
||||
a.getMonth() === b.getMonth() &&
|
||||
a.getDate() === b.getDate()
|
||||
a.getFullYear() === b.getFullYear()
|
||||
&& a.getMonth() === b.getMonth()
|
||||
&& a.getDate() === b.getDate()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the date/time display for a NIP-52 calendar event.
|
||||
*
|
||||
* Kind 31922 (date-based): "Jan 15, 2026" or "Jan 15 - Jan 17, 2026"
|
||||
* Kind 31923 (time-based): "Jan 15, 2026 at 3:00 PM" or time ranges
|
||||
*/
|
||||
function formatEventDate(event: NostrEvent): string {
|
||||
const start = getTag(event.tags, 'start');
|
||||
if (!start) return '';
|
||||
|
||||
if (event.kind === 31922) {
|
||||
// Date-based: start/end are YYYY-MM-DD strings
|
||||
// Parse as UTC to avoid timezone shifting the date
|
||||
const startDate = new Date(start + 'T00:00:00Z');
|
||||
const startDate = new Date(`${start}T00:00:00Z`);
|
||||
if (isNaN(startDate.getTime())) return start;
|
||||
|
||||
const end = getTag(event.tags, 'end');
|
||||
if (end) {
|
||||
const endDate = new Date(end + 'T00:00:00Z');
|
||||
const endDate = new Date(`${end}T00:00:00Z`);
|
||||
if (!isNaN(endDate.getTime()) && endDate > startDate) {
|
||||
// Multi-day range: "Jan 15 - Jan 17, 2026"
|
||||
// NIP-52: end date is exclusive, so display the last inclusive day
|
||||
const lastDay = new Date(endDate.getTime() - 86400000);
|
||||
if (lastDay > startDate) {
|
||||
const startParts = dateFormatter.formatToParts(startDate);
|
||||
const startStr = startParts
|
||||
.filter((p) => p.type !== 'year' && p.type !== 'literal' || p.value === ' ')
|
||||
.map((p) => (p.type === 'literal' && p.value.includes(',') ? '' : p.value))
|
||||
.join('')
|
||||
.trim();
|
||||
return `${startStr} – ${dateFormatter.format(lastDay)}`;
|
||||
const startStr = dateFormatter.format(startDate).replace(/, \d{4}$/, '');
|
||||
return `${startStr} - ${dateFormatter.format(lastDay)}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -113,7 +88,6 @@ function formatEventDate(event: NostrEvent): string {
|
||||
}
|
||||
|
||||
if (event.kind === 31923) {
|
||||
// Time-based: start/end are Unix timestamps
|
||||
const startTs = parseInt(start, 10);
|
||||
if (isNaN(startTs)) return start;
|
||||
const startDate = new Date(startTs * 1000);
|
||||
@@ -123,13 +97,10 @@ function formatEventDate(event: NostrEvent): string {
|
||||
const endTs = parseInt(end, 10);
|
||||
if (!isNaN(endTs) && endTs > startTs) {
|
||||
const endDate = new Date(endTs * 1000);
|
||||
|
||||
if (isSameDay(startDate, endDate)) {
|
||||
// Same day: "Jan 15, 2026 at 3:00 PM – 5:00 PM"
|
||||
return `${dateTimeFormatter.format(startDate)} – ${timeFormatter.format(endDate)}`;
|
||||
return `${dateTimeFormatter.format(startDate)} - ${timeFormatter.format(endDate)}`;
|
||||
}
|
||||
// Different days: "Jan 15, 2026 at 3:00 PM – Jan 16, 2026 at 5:00 PM"
|
||||
return `${dateTimeFormatter.format(startDate)} – ${dateTimeFormatter.format(endDate)}`;
|
||||
return `${dateTimeFormatter.format(startDate)} - ${dateTimeFormatter.format(endDate)}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,173 +110,162 @@ function formatEventDate(event: NostrEvent): string {
|
||||
return start;
|
||||
}
|
||||
|
||||
function getEventEndTimestamp(event: NostrEvent): number {
|
||||
const start = getTag(event.tags, 'start');
|
||||
if (!start) return 0;
|
||||
|
||||
if (event.kind === 31922) {
|
||||
const end = getTag(event.tags, 'end');
|
||||
const endDate = new Date(`${end || start}T00:00:00Z`);
|
||||
if (isNaN(endDate.getTime())) return 0;
|
||||
return Math.floor(endDate.getTime() / 1000) + (end ? 0 : 86400);
|
||||
}
|
||||
|
||||
const end = getTag(event.tags, 'end') || start;
|
||||
const endTs = parseInt(end, 10);
|
||||
return isNaN(endTs) ? 0 : endTs;
|
||||
}
|
||||
|
||||
/** Renders NIP-52 calendar event content (kind 31922 and 31923). */
|
||||
export function CalendarEventContent({ event, compact, className }: CalendarEventContentProps) {
|
||||
const title = useMemo(() => getTag(event.tags, 'title'), [event.tags]);
|
||||
const image = useMemo(() => getTag(event.tags, 'image'), [event.tags]);
|
||||
const image = useMemo(() => sanitizeUrl(getTag(event.tags, 'image')), [event.tags]);
|
||||
const locationRaw = useMemo(() => getTag(event.tags, 'location'), [event.tags]);
|
||||
const location = useMemo(() => locationRaw ? parseLocation(locationRaw) : undefined, [locationRaw]);
|
||||
const dateDisplay = useMemo(() => formatEventDate(event), [event]);
|
||||
const hashtags = useMemo(() => getAllTags(event.tags, 't').map(([, v]) => v).filter(Boolean), [event.tags]);
|
||||
const participants = useMemo(() => getAllTags(event.tags, 'p'), [event.tags]);
|
||||
const hasContent = event.content.trim().length > 0;
|
||||
const summary = useMemo(() => getTag(event.tags, 'summary'), [event.tags]);
|
||||
const ended = useMemo(() => getEventEndTimestamp(event) < Math.floor(Date.now() / 1000), [event]);
|
||||
const hasContent = event.content.trim().length > 0;
|
||||
|
||||
const participantPubkeys = useMemo(
|
||||
() => participants.map(([, pubkey]) => pubkey).filter(Boolean),
|
||||
[participants],
|
||||
);
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<div className={cn('mt-3 space-y-3', className)}>
|
||||
{image && (
|
||||
<div className="relative -mx-4 aspect-[21/9] overflow-hidden">
|
||||
<img src={image} alt={title ?? 'Calendar event'} className="w-full h-full object-cover" loading="lazy" />
|
||||
{participantPubkeys.length > 0 && (
|
||||
<div className="absolute bottom-2 left-3">
|
||||
<RSVPAvatars pubkeys={participantPubkeys} maxVisible={4} size="md" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<CalendarDays className="size-4 text-primary shrink-0" />
|
||||
<h3 className="font-semibold text-[15px] leading-tight line-clamp-2">{title ?? 'Untitled event'}</h3>
|
||||
</div>
|
||||
{ended ? (
|
||||
<Badge variant="secondary" className="shrink-0">Ended</Badge>
|
||||
) : dateDisplay ? (
|
||||
<span className="flex items-center gap-1 text-xs text-muted-foreground shrink-0 max-w-[45%]">
|
||||
<Clock className="size-3" />
|
||||
<span className="truncate">{dateDisplay}</span>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{dateDisplay && ended && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Clock className="size-3" />
|
||||
<span>{dateDisplay}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(summary || hasContent) && (
|
||||
<div className="text-sm text-muted-foreground leading-relaxed line-clamp-2">
|
||||
{summary && !hasContent ? (
|
||||
<p>{summary}</p>
|
||||
) : (
|
||||
<NoteContent event={event} className="text-sm" hideEmbedImages />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(location || participantPubkeys.length > 0) && (
|
||||
<div className="flex items-center gap-2.5 rounded-lg bg-muted/50 px-3 py-2">
|
||||
{location ? (
|
||||
<>
|
||||
<MapPin className="h-4 w-4 shrink-0 text-red-500" />
|
||||
<span className="text-sm truncate flex-1">{location}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Users className="h-4 w-4 shrink-0 text-primary" />
|
||||
<span className="text-sm text-muted-foreground flex-1">Participants</span>
|
||||
</>
|
||||
)}
|
||||
{participantPubkeys.length > 0 && (
|
||||
<RSVPAvatars pubkeys={participantPubkeys} maxVisible={4} size="sm" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hashtags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{hashtags.slice(0, 4).map((tag) => (
|
||||
<Badge key={tag} variant="secondary" className="text-[11px] px-2 py-0.5">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('mt-2 rounded-xl border border-border overflow-hidden', className)}>
|
||||
{compact ? (
|
||||
/* ── Compact feed card matching reference design ── */
|
||||
<>
|
||||
{/* Cover image with capped height, or gradient placeholder */}
|
||||
{image ? (
|
||||
<div className="relative h-[180px] overflow-hidden">
|
||||
<img
|
||||
src={image}
|
||||
alt={title ?? 'Calendar event'}
|
||||
className="h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
{/* Participant avatars overlaid on the image */}
|
||||
{participantPubkeys.length > 0 && (
|
||||
<div className="absolute bottom-2 left-3">
|
||||
<RSVPAvatars pubkeys={participantPubkeys} maxVisible={4} size="md" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative flex items-center justify-center bg-gradient-to-br from-primary/10 via-primary/5 to-transparent h-[100px]">
|
||||
<CalendarDays className="h-10 w-10 text-primary/30" />
|
||||
{participantPubkeys.length > 0 && (
|
||||
<div className="absolute bottom-2 left-3">
|
||||
<RSVPAvatars pubkeys={participantPubkeys} maxVisible={4} size="md" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Event details below image */}
|
||||
<div className="p-3 space-y-2">
|
||||
{/* Title */}
|
||||
{title && (
|
||||
<h3 className="text-base font-bold leading-snug line-clamp-2">{title}</h3>
|
||||
)}
|
||||
|
||||
{/* Date/time */}
|
||||
{dateDisplay && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Clock className="h-3.5 w-3.5 shrink-0" />
|
||||
<span>{dateDisplay}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description snippet — hard-capped to ~2 lines */}
|
||||
{(summary || hasContent) && (
|
||||
<div className="text-sm text-muted-foreground max-h-[2.8em] overflow-hidden relative">
|
||||
{summary && !hasContent ? (
|
||||
<p className="line-clamp-2">{summary}</p>
|
||||
) : (
|
||||
<NoteContent event={event} className="text-sm" hideEmbedImages />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Location pill */}
|
||||
{location && (
|
||||
<div className="flex items-center gap-2.5 rounded-lg bg-secondary/50 px-3 py-2">
|
||||
<MapPin className="h-4 w-4 shrink-0 text-red-500" />
|
||||
<span className="text-sm truncate">{location}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hashtags */}
|
||||
{hashtags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{hashtags.slice(0, 4).map((tag) => (
|
||||
<Badge key={tag} variant="secondary" className="text-[11px] px-2 py-0.5">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
{image ? (
|
||||
<div className="aspect-video rounded-lg overflow-hidden">
|
||||
<img src={image} alt={title ?? 'Calendar event'} className="h-full w-full object-cover" loading="lazy" />
|
||||
</div>
|
||||
) : (
|
||||
/* ── Full detail layout (detail page, expanded view) ── */
|
||||
<>
|
||||
{/* Cover image or gradient header */}
|
||||
{image ? (
|
||||
<div className="aspect-video rounded-lg overflow-hidden">
|
||||
<img
|
||||
src={image}
|
||||
alt={title ?? 'Calendar event'}
|
||||
className="h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center bg-gradient-to-br from-primary/10 via-primary/5 to-transparent py-8">
|
||||
<CalendarDays className="h-10 w-10 text-primary/30" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Event details */}
|
||||
<div className="space-y-2 p-3">
|
||||
{title && (
|
||||
<h3 className="text-[15px] font-semibold leading-snug">{title}</h3>
|
||||
)}
|
||||
|
||||
{dateDisplay && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Clock className="h-3.5 w-3.5 shrink-0" />
|
||||
<span>{dateDisplay}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{location && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<MapPin className="h-3.5 w-3.5 shrink-0" />
|
||||
<span>{location}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{participants.length > 0 && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Users className="h-3.5 w-3.5 shrink-0" />
|
||||
<span>
|
||||
{participants.length} {participants.length === 1 ? 'participant' : 'participants'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{summary && !hasContent && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{summary}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{hasContent && (
|
||||
<div>
|
||||
<NoteContent event={event} className="text-sm" hideEmbedImages={!!image} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hashtags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 pt-1">
|
||||
{hashtags.map((tag) => (
|
||||
<Badge key={tag} variant="secondary" className="text-[11px] px-2 py-0">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
<div className="flex items-center justify-center bg-gradient-to-br from-primary/10 via-primary/5 to-transparent py-8">
|
||||
<CalendarDays className="h-10 w-10 text-primary/30" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2 p-3">
|
||||
{title && <h3 className="text-[15px] font-semibold leading-snug">{title}</h3>}
|
||||
{dateDisplay && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Clock className="h-3.5 w-3.5 shrink-0" />
|
||||
<span>{dateDisplay}</span>
|
||||
</div>
|
||||
)}
|
||||
{location && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<MapPin className="h-3.5 w-3.5 shrink-0" />
|
||||
<span>{location}</span>
|
||||
</div>
|
||||
)}
|
||||
{participants.length > 0 && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Users className="h-3.5 w-3.5 shrink-0" />
|
||||
<span>{participants.length} {participants.length === 1 ? 'participant' : 'participants'}</span>
|
||||
</div>
|
||||
)}
|
||||
{summary && !hasContent && <p className="text-sm text-muted-foreground">{summary}</p>}
|
||||
{hasContent && <NoteContent event={event} className="text-sm" hideEmbedImages={!!image} />}
|
||||
{hashtags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 pt-1">
|
||||
{hashtags.map((tag) => (
|
||||
<Badge key={tag} variant="secondary" className="text-[11px] px-2 py-0">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify';
|
||||
|
||||
import { AddMemberDialog } from '@/components/AddMemberDialog';
|
||||
import { CreateCommunityDialog } from '@/components/CreateCommunityDialog';
|
||||
import { CreateCommunityEventDialog } from '@/components/CreateCommunityEventDialog';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { getAvatarShape } from '@/lib/avatarShape';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -143,6 +144,22 @@ function getCalendarEventStart(event: NostrEvent): number {
|
||||
return isNaN(timestamp) ? 0 : timestamp;
|
||||
}
|
||||
|
||||
function getCalendarEventEnd(event: NostrEvent): number {
|
||||
const start = getTag(event.tags, 'start');
|
||||
if (!start) return 0;
|
||||
|
||||
if (event.kind === 31922) {
|
||||
const end = getTag(event.tags, 'end');
|
||||
const endDate = new Date(`${end || start}T00:00:00Z`);
|
||||
if (isNaN(endDate.getTime())) return 0;
|
||||
return Math.floor(endDate.getTime() / 1000) + (end ? 0 : 86400);
|
||||
}
|
||||
|
||||
const end = getTag(event.tags, 'end') || start;
|
||||
const endTs = parseInt(end, 10);
|
||||
return isNaN(endTs) ? 0 : endTs;
|
||||
}
|
||||
|
||||
// ── Main component ────────────────────────────────────────────────────────────
|
||||
|
||||
export function CommunityDetailPage({ event }: { event: NostrEvent }) {
|
||||
@@ -158,6 +175,7 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
|
||||
const [activeTab, setActiveTab] = useState('members');
|
||||
const [composeOpen, setComposeOpen] = useState(false);
|
||||
const [goalDialogOpen, setGoalDialogOpen] = useState(false);
|
||||
const [eventDialogOpen, setEventDialogOpen] = useState(false);
|
||||
const [addMemberOpen, setAddMemberOpen] = useState(false);
|
||||
const [editCommunityOpen, setEditCommunityOpen] = useState(false);
|
||||
|
||||
@@ -294,6 +312,14 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
|
||||
return bStart - aStart;
|
||||
});
|
||||
}, [communityEvents, moderation, membersOnly, rankMap, now]);
|
||||
const activeEventItems = useMemo(
|
||||
() => eventItems.filter((e) => getCalendarEventEnd(e) >= now),
|
||||
[eventItems, now],
|
||||
);
|
||||
const pastEventItems = useMemo(
|
||||
() => eventItems.filter((e) => getCalendarEventEnd(e) < now),
|
||||
[eventItems, now],
|
||||
);
|
||||
|
||||
const replyTree = useMemo((): ReplyNode[] => {
|
||||
if (!commentsData) return [];
|
||||
@@ -353,6 +379,8 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
|
||||
setComposeOpen(true);
|
||||
} else if (activeTab === 'fundraising') {
|
||||
setGoalDialogOpen(true);
|
||||
} else if (activeTab === 'events') {
|
||||
setEventDialogOpen(true);
|
||||
} else if (activeTab === 'members') {
|
||||
setAddMemberOpen(true);
|
||||
}
|
||||
@@ -362,7 +390,9 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
|
||||
? <Target strokeWidth={3} size={18} />
|
||||
: activeTab === 'members'
|
||||
? <UserPlus className="size-5" />
|
||||
: undefined; // default Plus icon for comments
|
||||
: activeTab === 'events'
|
||||
? <CalendarDays className="size-5" />
|
||||
: undefined; // default Plus icon for comments
|
||||
|
||||
useLayoutOptions({
|
||||
showFAB:
|
||||
@@ -370,8 +400,7 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
|
||||
|| activeTab === 'fundraising'
|
||||
|| activeTab === 'events'
|
||||
|| (activeTab === 'members' && canAddMembers),
|
||||
fabKind: activeTab === 'events' ? 31923 : 1,
|
||||
onFabClick: activeTab === 'events' ? undefined : handleFabClick,
|
||||
onFabClick: handleFabClick,
|
||||
fabIcon,
|
||||
});
|
||||
|
||||
@@ -608,7 +637,7 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
|
||||
<ReplyCardSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
) : eventItems.length === 0 ? (
|
||||
) : activeEventItems.length === 0 && pastEventItems.length === 0 ? (
|
||||
<div className="py-12 text-center text-muted-foreground text-sm px-5">
|
||||
{membersOnly && (communityEvents ?? []).length > 0
|
||||
? 'No events from community members yet. Toggle the shield icon to see all events.'
|
||||
@@ -616,7 +645,18 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border">
|
||||
{eventItems.map((e) => (
|
||||
{activeEventItems.map((e) => (
|
||||
<NoteCard key={e.id} event={e} />
|
||||
))}
|
||||
|
||||
{pastEventItems.length > 0 && activeEventItems.length > 0 && (
|
||||
<div className="px-5 pt-4 pb-1">
|
||||
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
Past Events
|
||||
</h4>
|
||||
</div>
|
||||
)}
|
||||
{pastEventItems.map((e) => (
|
||||
<NoteCard key={e.id} event={e} />
|
||||
))}
|
||||
</div>
|
||||
@@ -656,6 +696,15 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* FAB-triggered event creation dialog for the events tab */}
|
||||
{communityATag && (
|
||||
<CreateCommunityEventDialog
|
||||
communityATag={communityATag}
|
||||
open={eventDialogOpen}
|
||||
onOpenChange={setEventDialogOpen}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Add member dialog */}
|
||||
{canAddMembers && community && (
|
||||
<AddMemberDialog
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { CalendarDays, ChevronLeft } from 'lucide-react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
|
||||
interface CreateCommunityEventDialogProps {
|
||||
communityATag: string;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
function slugify(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^\w\s-]/g, '')
|
||||
.replace(/[\s_]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
function addDays(date: string, days: number): string {
|
||||
const parsed = new Date(`${date}T00:00:00Z`);
|
||||
parsed.setUTCDate(parsed.getUTCDate() + days);
|
||||
return parsed.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function toLocalTimestamp(date: string, time: string): number {
|
||||
return Math.floor(new Date(`${date}T${time}:00`).getTime() / 1000);
|
||||
}
|
||||
|
||||
function parseCommunityAuthor(communityATag: string): string | undefined {
|
||||
const [, pubkey] = communityATag.split(':');
|
||||
return pubkey || undefined;
|
||||
}
|
||||
|
||||
export function CreateCommunityEventDialog({ communityATag, open, onOpenChange }: CreateCommunityEventDialogProps) {
|
||||
const { user } = useCurrentUser();
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const { mutateAsync: publishEvent, isPending } = useNostrPublish();
|
||||
|
||||
const [step, setStep] = useState<1 | 2>(1);
|
||||
const [title, setTitle] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [imageUrl, setImageUrl] = useState('');
|
||||
const [allDay, setAllDay] = useState(true);
|
||||
const [startDate, setStartDate] = useState('');
|
||||
const [startTime, setStartTime] = useState('');
|
||||
const [endDate, setEndDate] = useState('');
|
||||
const [endTime, setEndTime] = useState('');
|
||||
const [location, setLocation] = useState('');
|
||||
|
||||
const timezone = useMemo(
|
||||
() => Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC',
|
||||
[],
|
||||
);
|
||||
|
||||
const resetForm = useCallback(() => {
|
||||
setStep(1);
|
||||
setTitle('');
|
||||
setDescription('');
|
||||
setImageUrl('');
|
||||
setAllDay(true);
|
||||
setStartDate('');
|
||||
setStartTime('');
|
||||
setEndDate('');
|
||||
setEndTime('');
|
||||
setLocation('');
|
||||
}, []);
|
||||
|
||||
const handleOpenChange = useCallback((nextOpen: boolean) => {
|
||||
if (!nextOpen) resetForm();
|
||||
onOpenChange(nextOpen);
|
||||
}, [onOpenChange, resetForm]);
|
||||
|
||||
const validateInfoStep = useCallback((): boolean => {
|
||||
if (!title.trim()) {
|
||||
toast({ title: 'Enter an event title', variant: 'destructive' });
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}, [title, toast]);
|
||||
|
||||
const handleNext = useCallback(() => {
|
||||
if (!validateInfoStep()) return;
|
||||
setStep(2);
|
||||
}, [validateInfoStep]);
|
||||
|
||||
const handleSubmit = useCallback(async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!user) return;
|
||||
if (!validateInfoStep()) return;
|
||||
|
||||
if (!startDate) {
|
||||
toast({ title: 'Choose a start date', variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!allDay && !startTime) {
|
||||
toast({ title: 'Choose a start time or turn on all-day', variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!allDay && endDate && !endTime) {
|
||||
toast({ title: 'Add an end time or clear the end date', variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmedTitle = title.trim();
|
||||
const dTag = `${slugify(trimmedTitle) || 'event'}-${Date.now()}`;
|
||||
const communityAuthor = parseCommunityAuthor(communityATag);
|
||||
const tags: string[][] = [
|
||||
['d', dTag],
|
||||
['title', trimmedTitle],
|
||||
['A', communityATag],
|
||||
['K', '34550'],
|
||||
['alt', `Community event: ${trimmedTitle}`],
|
||||
];
|
||||
|
||||
if (communityAuthor) {
|
||||
tags.push(['P', communityAuthor]);
|
||||
}
|
||||
|
||||
if (description.trim()) {
|
||||
tags.push(['summary', description.trim()]);
|
||||
}
|
||||
|
||||
if (location.trim()) {
|
||||
tags.push(['location', location.trim()]);
|
||||
}
|
||||
|
||||
if (imageUrl.trim()) {
|
||||
const sanitizedImage = sanitizeUrl(imageUrl.trim());
|
||||
if (!sanitizedImage) {
|
||||
toast({ title: 'Image URL must be a valid https URL', variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
tags.push(['image', sanitizedImage]);
|
||||
}
|
||||
|
||||
let kind = 31922;
|
||||
if (allDay) {
|
||||
tags.push(['start', startDate]);
|
||||
if (endDate) {
|
||||
if (endDate < startDate) {
|
||||
toast({ title: 'End date must be on or after the start date', variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
tags.push(['end', addDays(endDate, 1)]);
|
||||
}
|
||||
} else {
|
||||
kind = 31923;
|
||||
const startTs = toLocalTimestamp(startDate, startTime);
|
||||
if (!Number.isFinite(startTs) || startTs <= 0) {
|
||||
toast({ title: 'Start date or time is invalid', variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
tags.push(['start', String(startTs)]);
|
||||
tags.push(['D', String(Math.floor(startTs / 86400))]);
|
||||
tags.push(['start_tzid', timezone]);
|
||||
|
||||
if (endTime) {
|
||||
const effectiveEndDate = endDate || startDate;
|
||||
const endTs = toLocalTimestamp(effectiveEndDate, endTime);
|
||||
if (!Number.isFinite(endTs) || endTs <= startTs) {
|
||||
toast({ title: 'End time must be after the start time', variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
tags.push(['end', String(endTs)]);
|
||||
tags.push(['end_tzid', timezone]);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await publishEvent({
|
||||
kind,
|
||||
content: description.trim(),
|
||||
tags,
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['community-events', communityATag] }),
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (q) => {
|
||||
const [root, aTagsKey] = q.queryKey;
|
||||
return root === 'community-activity-feed'
|
||||
&& typeof aTagsKey === 'string'
|
||||
&& aTagsKey.split(',').includes(communityATag);
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
toast({ title: 'Event created!' });
|
||||
handleOpenChange(false);
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Failed to create event',
|
||||
description: err instanceof Error ? err.message : 'Please try again.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
}, [
|
||||
allDay,
|
||||
communityATag,
|
||||
description,
|
||||
endDate,
|
||||
endTime,
|
||||
handleOpenChange,
|
||||
imageUrl,
|
||||
location,
|
||||
publishEvent,
|
||||
queryClient,
|
||||
startDate,
|
||||
startTime,
|
||||
timezone,
|
||||
title,
|
||||
toast,
|
||||
user,
|
||||
validateInfoStep,
|
||||
]);
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="sm:max-w-md gap-0 p-0 overflow-hidden">
|
||||
<DialogHeader className="px-5 pt-5 pb-3">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<CalendarDays className="size-5 text-primary" />
|
||||
Create Event
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Step {step} of 2 · {step === 1 ? 'What is happening?' : 'When and where?'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<ScrollArea className="max-h-[62vh]">
|
||||
<div className="px-5 pb-5 space-y-4">
|
||||
{step === 1 ? (
|
||||
<>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="community-event-title">Title *</Label>
|
||||
<Input
|
||||
id="community-event-title"
|
||||
placeholder="e.g. Neighborhood cleanup"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="community-event-description">Description (recommended)</Label>
|
||||
<Textarea
|
||||
id="community-event-description"
|
||||
placeholder="Tell people what to expect..."
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="community-event-image">Image URL (optional)</Label>
|
||||
<Input
|
||||
id="community-event-image"
|
||||
type="url"
|
||||
placeholder="https://..."
|
||||
value={imageUrl}
|
||||
onChange={(e) => setImageUrl(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-4 rounded-xl border border-border px-3 py-3">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="community-event-all-day">All-day event</Label>
|
||||
<p className="text-xs text-muted-foreground">Turn off to add start and end times.</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="community-event-all-day"
|
||||
checked={allDay}
|
||||
onCheckedChange={setAllDay}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(9.5rem,1fr))] gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="community-event-start-date">Start date *</Label>
|
||||
<Input
|
||||
id="community-event-start-date"
|
||||
type="date"
|
||||
className="[color-scheme:light] dark:[color-scheme:dark]"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="community-event-end-date">End date (optional)</Label>
|
||||
<Input
|
||||
id="community-event-end-date"
|
||||
type="date"
|
||||
className="[color-scheme:light] dark:[color-scheme:dark]"
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!allDay && (
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(9.5rem,1fr))] gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="community-event-start-time">Start time *</Label>
|
||||
<Input
|
||||
id="community-event-start-time"
|
||||
type="time"
|
||||
className="[color-scheme:light] dark:[color-scheme:dark]"
|
||||
value={startTime}
|
||||
onChange={(e) => setStartTime(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="community-event-end-time">End time (optional)</Label>
|
||||
<Input
|
||||
id="community-event-end-time"
|
||||
type="time"
|
||||
className="[color-scheme:light] dark:[color-scheme:dark]"
|
||||
value={endTime}
|
||||
onChange={(e) => setEndTime(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="community-event-location">Location (recommended)</Label>
|
||||
<Input
|
||||
id="community-event-location"
|
||||
placeholder="Address, venue, or video call link"
|
||||
value={location}
|
||||
onChange={(e) => setLocation(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<div className="flex items-center gap-2 border-t border-border px-5 py-4 bg-background">
|
||||
{step === 1 ? (
|
||||
<>
|
||||
<Button type="button" variant="outline" className="flex-1" onClick={() => handleOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="button" className="flex-1" onClick={handleNext}>
|
||||
Next
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button type="button" variant="outline" className="flex-1 gap-1.5" onClick={() => setStep(1)}>
|
||||
<ChevronLeft className="size-4" />
|
||||
Back
|
||||
</Button>
|
||||
<Button type="submit" className="flex-1" disabled={isPending}>
|
||||
{isPending ? 'Creating...' : 'Create Event'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user