mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-10 23:47:12 +02:00
feat(calendar): add renderers for NIP-52 calendar events (kinds 31922 & 31923) (#43)
* feat(calendar): add renderers for NIP-52 calendar events (kinds 31922 & 31923)
- Add CalendarDays and CalendarClock icons for date/time event kinds
- Create calendar-event.ts helper with parsing and formatting functions
- Add feed renderers showing status badge, title, date/time, location, participant count
- Add detail renderers with full description, participant list with names, tags, and links
- Register renderers in kinds/index.tsx for both feed and detail views
- Use locale-aware date/time formatting throughout
* refactor(calendar): improve feed renderer layout
- Rename both kinds to "Calendar Event" for consistency
- Move date/time info below title
- Place time on left, status badge on right with justify-between
- Remove timezone indicator from feed view (keep in detail)
* refactor(calendar): apply feed layout to detail views
- Move title above date/time in detail views
- Use justify-between for time left, status badge right
- Use Label component for hashtags (no # prefix, consistent with feed)
- Use Label component for participant roles (subtle dotted border style)
* refactor(calendar): separate location/tags rows and tone down time font
- Separate location/participants from hashtags onto different rows in feed
- Reduce time font size: text-xs in feed, text-sm in detail (was text-sm/text-lg)
- Remove font-medium from time display
* style(calendar): tone down status badges to match date/time styling
Replace bold background-colored badges with subtle text-colored badges:
- Use text-blue-500, text-green-500, text-muted-foreground instead of backgrounds
- Lowercase labels ("upcoming", "now", "past")
- Consistent sizing with date/time text
* refactor(calendar): extract shared components and add caching
- Extract CalendarStatusBadge to shared component with variant/size props
- Add symbol-based caching to parseDateCalendarEvent and parseTimeCalendarEvent
using applesauce's getOrComputeCachedValue for performance
- Use 'd' tag (identifier) as title fallback instead of "Untitled Event"
- Remove duplicate CalendarStatusBadge implementations from all 4 renderers
- Remove unused imports (cn, CalendarDays, CalendarClock, Clock, CheckCircle)
---------
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
71
src/components/nostr/calendar/CalendarStatusBadge.tsx
Normal file
71
src/components/nostr/calendar/CalendarStatusBadge.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { Clock, CheckCircle, CalendarDays, CalendarClock } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { CalendarEventStatus } from "@/lib/calendar-event";
|
||||
|
||||
interface StatusConfig {
|
||||
label: string;
|
||||
className: string;
|
||||
icon: LucideIcon;
|
||||
}
|
||||
|
||||
const STATUS_CONFIG: Record<
|
||||
CalendarEventStatus,
|
||||
{ label: string; className: string }
|
||||
> = {
|
||||
upcoming: { label: "upcoming", className: "text-blue-500" },
|
||||
ongoing: { label: "now", className: "text-green-500" },
|
||||
past: { label: "past", className: "text-muted-foreground" },
|
||||
};
|
||||
|
||||
interface CalendarStatusBadgeProps {
|
||||
status: CalendarEventStatus;
|
||||
/** Icon variant - use CalendarDays for date events, CalendarClock for time events */
|
||||
variant?: "date" | "time";
|
||||
/** Size variant - sm for feed, md for detail views */
|
||||
size?: "sm" | "md";
|
||||
}
|
||||
|
||||
/**
|
||||
* Status badge for calendar events (NIP-52)
|
||||
* Displays the current status (upcoming/now/past) with an appropriate icon
|
||||
*/
|
||||
export function CalendarStatusBadge({
|
||||
status,
|
||||
variant = "date",
|
||||
size = "sm",
|
||||
}: CalendarStatusBadgeProps) {
|
||||
const baseConfig = STATUS_CONFIG[status];
|
||||
const OngoingIcon = variant === "time" ? CalendarClock : CalendarDays;
|
||||
|
||||
const config: StatusConfig = {
|
||||
...baseConfig,
|
||||
icon:
|
||||
status === "ongoing"
|
||||
? OngoingIcon
|
||||
: status === "upcoming"
|
||||
? Clock
|
||||
: CheckCircle,
|
||||
};
|
||||
|
||||
const Icon = config.icon;
|
||||
|
||||
const sizeClasses = {
|
||||
sm: { text: "text-xs", icon: "w-3 h-3", gap: "gap-1" },
|
||||
md: { text: "text-sm", icon: "w-4 h-4", gap: "gap-1" },
|
||||
}[size];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center flex-shrink-0",
|
||||
sizeClasses.text,
|
||||
sizeClasses.gap,
|
||||
config.className,
|
||||
)}
|
||||
>
|
||||
<Icon className={sizeClasses.icon} />
|
||||
<span>{config.label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
147
src/components/nostr/kinds/CalendarDateEventDetailRenderer.tsx
Normal file
147
src/components/nostr/kinds/CalendarDateEventDetailRenderer.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
import type { NostrEvent } from "@/types/nostr";
|
||||
import {
|
||||
parseDateCalendarEvent,
|
||||
getDateEventStatus,
|
||||
formatDateRange,
|
||||
} from "@/lib/calendar-event";
|
||||
import { UserName } from "../UserName";
|
||||
import { MarkdownContent } from "../MarkdownContent";
|
||||
import { CalendarStatusBadge } from "../calendar/CalendarStatusBadge";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { MapPin, Users, Hash, ExternalLink } from "lucide-react";
|
||||
|
||||
/**
|
||||
* Detail renderer for Kind 31922 - Date-Based Calendar Event
|
||||
* Displays full event details with participant list
|
||||
*/
|
||||
export function CalendarDateEventDetailRenderer({
|
||||
event,
|
||||
}: {
|
||||
event: NostrEvent;
|
||||
}) {
|
||||
const parsed = parseDateCalendarEvent(event);
|
||||
const status = getDateEventStatus(parsed);
|
||||
const dateRange = formatDateRange(parsed.start, parsed.end);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 p-6 max-w-3xl mx-auto">
|
||||
{/* Event Header */}
|
||||
<header className="flex flex-col gap-4 border-b border-border pb-6">
|
||||
{/* Title */}
|
||||
<h1 className="text-3xl font-bold">
|
||||
{parsed.title || parsed.identifier}
|
||||
</h1>
|
||||
|
||||
{/* Date and Status: date left, badge right */}
|
||||
<div className="flex items-center justify-between">
|
||||
{dateRange && (
|
||||
<span className="text-sm text-muted-foreground">{dateRange}</span>
|
||||
)}
|
||||
<CalendarStatusBadge status={status} variant="date" size="md" />
|
||||
</div>
|
||||
|
||||
{/* Organizer */}
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>Organized by</span>
|
||||
<UserName pubkey={event.pubkey} className="font-semibold" />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Event Details */}
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Locations */}
|
||||
{parsed.locations.length > 0 && (
|
||||
<section className="flex flex-col gap-2">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-2">
|
||||
<MapPin className="w-4 h-4" />
|
||||
Location{parsed.locations.length > 1 ? "s" : ""}
|
||||
</h2>
|
||||
<ul className="flex flex-col gap-1">
|
||||
{parsed.locations.map((location, i) => (
|
||||
<li key={i} className="text-foreground">
|
||||
{location}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
{parsed.description && (
|
||||
<section className="flex flex-col gap-2">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
About this event
|
||||
</h2>
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none">
|
||||
<MarkdownContent content={parsed.description} />
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Participants */}
|
||||
{parsed.participants.length > 0 && (
|
||||
<section className="flex flex-col gap-2">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-2">
|
||||
<Users className="w-4 h-4" />
|
||||
Participants ({parsed.participants.length})
|
||||
</h2>
|
||||
<ul className="flex flex-col gap-2">
|
||||
{parsed.participants.map((participant) => (
|
||||
<li
|
||||
key={participant.pubkey}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<UserName pubkey={participant.pubkey} />
|
||||
{participant.role && (
|
||||
<Label size="sm">{participant.role}</Label>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Hashtags */}
|
||||
{parsed.hashtags.length > 0 && (
|
||||
<section className="flex flex-col gap-2">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-2">
|
||||
<Hash className="w-4 h-4" />
|
||||
Tags
|
||||
</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{parsed.hashtags.map((tag) => (
|
||||
<Label key={tag} size="sm">
|
||||
{tag}
|
||||
</Label>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* References */}
|
||||
{parsed.references.length > 0 && (
|
||||
<section className="flex flex-col gap-2">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-2">
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
Links
|
||||
</h2>
|
||||
<ul className="flex flex-col gap-1">
|
||||
{parsed.references.map((ref, i) => (
|
||||
<li key={i}>
|
||||
<a
|
||||
href={ref}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-accent hover:underline truncate block"
|
||||
>
|
||||
{ref}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
98
src/components/nostr/kinds/CalendarDateEventRenderer.tsx
Normal file
98
src/components/nostr/kinds/CalendarDateEventRenderer.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import {
|
||||
parseDateCalendarEvent,
|
||||
getDateEventStatus,
|
||||
formatDateRange,
|
||||
} from "@/lib/calendar-event";
|
||||
import {
|
||||
BaseEventContainer,
|
||||
ClickableEventTitle,
|
||||
type BaseEventProps,
|
||||
} from "./BaseEventRenderer";
|
||||
import { CalendarStatusBadge } from "../calendar/CalendarStatusBadge";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { MapPin, Users } from "lucide-react";
|
||||
|
||||
/**
|
||||
* Renderer for Kind 31922 - Date-Based Calendar Event
|
||||
* Displays event title, date range, location, and participant count in feed
|
||||
*/
|
||||
export function CalendarDateEventRenderer({ event }: BaseEventProps) {
|
||||
const parsed = parseDateCalendarEvent(event);
|
||||
const status = getDateEventStatus(parsed);
|
||||
const dateRange = formatDateRange(parsed.start, parsed.end);
|
||||
|
||||
return (
|
||||
<BaseEventContainer event={event}>
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* Title */}
|
||||
<ClickableEventTitle
|
||||
event={event}
|
||||
className="text-lg font-semibold text-foreground"
|
||||
>
|
||||
{parsed.title || parsed.identifier}
|
||||
</ClickableEventTitle>
|
||||
|
||||
{/* Date and status: time left, badge right */}
|
||||
<div className="flex items-center justify-between">
|
||||
{dateRange && (
|
||||
<span className="text-xs text-muted-foreground">{dateRange}</span>
|
||||
)}
|
||||
<CalendarStatusBadge status={status} variant="date" size="sm" />
|
||||
</div>
|
||||
|
||||
{/* Description preview */}
|
||||
{parsed.description && (
|
||||
<p className="text-sm text-muted-foreground line-clamp-2">
|
||||
{parsed.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Location and participants */}
|
||||
{(parsed.locations.length > 0 || parsed.participants.length > 0) && (
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
{/* Location */}
|
||||
{parsed.locations.length > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<MapPin className="w-3 h-3" />
|
||||
<span className="truncate max-w-[200px]">
|
||||
{parsed.locations[0]}
|
||||
{parsed.locations.length > 1 &&
|
||||
` +${parsed.locations.length - 1}`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Participant count */}
|
||||
{parsed.participants.length > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Users className="w-3 h-3" />
|
||||
<span>
|
||||
{parsed.participants.length}{" "}
|
||||
{parsed.participants.length === 1
|
||||
? "participant"
|
||||
: "participants"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hashtags */}
|
||||
{parsed.hashtags.length > 0 && (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{parsed.hashtags.slice(0, 3).map((tag) => (
|
||||
<Label key={tag} size="sm">
|
||||
{tag}
|
||||
</Label>
|
||||
))}
|
||||
{parsed.hashtags.length > 3 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
+{parsed.hashtags.length - 3}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</BaseEventContainer>
|
||||
);
|
||||
}
|
||||
165
src/components/nostr/kinds/CalendarTimeEventDetailRenderer.tsx
Normal file
165
src/components/nostr/kinds/CalendarTimeEventDetailRenderer.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
import type { NostrEvent } from "@/types/nostr";
|
||||
import {
|
||||
parseTimeCalendarEvent,
|
||||
getTimeEventStatus,
|
||||
formatTimeRange,
|
||||
} from "@/lib/calendar-event";
|
||||
import { UserName } from "../UserName";
|
||||
import { MarkdownContent } from "../MarkdownContent";
|
||||
import { CalendarStatusBadge } from "../calendar/CalendarStatusBadge";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { MapPin, Users, Hash, ExternalLink, Globe } from "lucide-react";
|
||||
|
||||
/**
|
||||
* Detail renderer for Kind 31923 - Time-Based Calendar Event
|
||||
* Displays full event details with participant list and timezone info
|
||||
*/
|
||||
export function CalendarTimeEventDetailRenderer({
|
||||
event,
|
||||
}: {
|
||||
event: NostrEvent;
|
||||
}) {
|
||||
const parsed = parseTimeCalendarEvent(event);
|
||||
const status = getTimeEventStatus(parsed);
|
||||
const timeRange = formatTimeRange(
|
||||
parsed.start,
|
||||
parsed.end,
|
||||
parsed.startTzid,
|
||||
parsed.endTzid,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 p-6 max-w-3xl mx-auto">
|
||||
{/* Event Header */}
|
||||
<header className="flex flex-col gap-4 border-b border-border pb-6">
|
||||
{/* Title */}
|
||||
<h1 className="text-3xl font-bold">
|
||||
{parsed.title || parsed.identifier}
|
||||
</h1>
|
||||
|
||||
{/* Time and Status: time left, badge right */}
|
||||
<div className="flex items-center justify-between">
|
||||
{timeRange && (
|
||||
<span className="text-sm text-muted-foreground">{timeRange}</span>
|
||||
)}
|
||||
<CalendarStatusBadge status={status} variant="time" size="md" />
|
||||
</div>
|
||||
|
||||
{/* Timezone indicator */}
|
||||
{parsed.startTzid && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Globe className="w-4 h-4" />
|
||||
<span>{parsed.startTzid}</span>
|
||||
{parsed.endTzid && parsed.endTzid !== parsed.startTzid && (
|
||||
<span className="text-muted-foreground/70">
|
||||
(ends in {parsed.endTzid})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Organizer */}
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>Organized by</span>
|
||||
<UserName pubkey={event.pubkey} className="font-semibold" />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Event Details */}
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Locations */}
|
||||
{parsed.locations.length > 0 && (
|
||||
<section className="flex flex-col gap-2">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-2">
|
||||
<MapPin className="w-4 h-4" />
|
||||
Location{parsed.locations.length > 1 ? "s" : ""}
|
||||
</h2>
|
||||
<ul className="flex flex-col gap-1">
|
||||
{parsed.locations.map((location, i) => (
|
||||
<li key={i} className="text-foreground">
|
||||
{location}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
{parsed.description && (
|
||||
<section className="flex flex-col gap-2">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
About this event
|
||||
</h2>
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none">
|
||||
<MarkdownContent content={parsed.description} />
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Participants */}
|
||||
{parsed.participants.length > 0 && (
|
||||
<section className="flex flex-col gap-2">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-2">
|
||||
<Users className="w-4 h-4" />
|
||||
Participants ({parsed.participants.length})
|
||||
</h2>
|
||||
<ul className="flex flex-col gap-2">
|
||||
{parsed.participants.map((participant) => (
|
||||
<li
|
||||
key={participant.pubkey}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<UserName pubkey={participant.pubkey} />
|
||||
{participant.role && (
|
||||
<Label size="sm">{participant.role}</Label>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Hashtags */}
|
||||
{parsed.hashtags.length > 0 && (
|
||||
<section className="flex flex-col gap-2">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-2">
|
||||
<Hash className="w-4 h-4" />
|
||||
Tags
|
||||
</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{parsed.hashtags.map((tag) => (
|
||||
<Label key={tag} size="sm">
|
||||
{tag}
|
||||
</Label>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* References */}
|
||||
{parsed.references.length > 0 && (
|
||||
<section className="flex flex-col gap-2">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-2">
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
Links
|
||||
</h2>
|
||||
<ul className="flex flex-col gap-1">
|
||||
{parsed.references.map((ref, i) => (
|
||||
<li key={i}>
|
||||
<a
|
||||
href={ref}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-accent hover:underline truncate block"
|
||||
>
|
||||
{ref}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
103
src/components/nostr/kinds/CalendarTimeEventRenderer.tsx
Normal file
103
src/components/nostr/kinds/CalendarTimeEventRenderer.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import {
|
||||
parseTimeCalendarEvent,
|
||||
getTimeEventStatus,
|
||||
formatTimeRange,
|
||||
} from "@/lib/calendar-event";
|
||||
import {
|
||||
BaseEventContainer,
|
||||
ClickableEventTitle,
|
||||
type BaseEventProps,
|
||||
} from "./BaseEventRenderer";
|
||||
import { CalendarStatusBadge } from "../calendar/CalendarStatusBadge";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { MapPin, Users } from "lucide-react";
|
||||
|
||||
/**
|
||||
* Renderer for Kind 31923 - Time-Based Calendar Event
|
||||
* Displays event title, time range with timezone, location, and participant count in feed
|
||||
*/
|
||||
export function CalendarTimeEventRenderer({ event }: BaseEventProps) {
|
||||
const parsed = parseTimeCalendarEvent(event);
|
||||
const status = getTimeEventStatus(parsed);
|
||||
const timeRange = formatTimeRange(
|
||||
parsed.start,
|
||||
parsed.end,
|
||||
parsed.startTzid,
|
||||
parsed.endTzid,
|
||||
);
|
||||
|
||||
return (
|
||||
<BaseEventContainer event={event}>
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* Title */}
|
||||
<ClickableEventTitle
|
||||
event={event}
|
||||
className="text-lg font-semibold text-foreground"
|
||||
>
|
||||
{parsed.title || parsed.identifier}
|
||||
</ClickableEventTitle>
|
||||
|
||||
{/* Time and status: time left, badge right */}
|
||||
<div className="flex items-center justify-between">
|
||||
{timeRange && (
|
||||
<span className="text-xs text-muted-foreground">{timeRange}</span>
|
||||
)}
|
||||
<CalendarStatusBadge status={status} variant="time" size="sm" />
|
||||
</div>
|
||||
|
||||
{/* Description preview */}
|
||||
{parsed.description && (
|
||||
<p className="text-sm text-muted-foreground line-clamp-2">
|
||||
{parsed.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Location and participants */}
|
||||
{(parsed.locations.length > 0 || parsed.participants.length > 0) && (
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
{/* Location */}
|
||||
{parsed.locations.length > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<MapPin className="w-3 h-3" />
|
||||
<span className="truncate max-w-[200px]">
|
||||
{parsed.locations[0]}
|
||||
{parsed.locations.length > 1 &&
|
||||
` +${parsed.locations.length - 1}`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Participant count */}
|
||||
{parsed.participants.length > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Users className="w-3 h-3" />
|
||||
<span>
|
||||
{parsed.participants.length}{" "}
|
||||
{parsed.participants.length === 1
|
||||
? "participant"
|
||||
: "participants"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hashtags */}
|
||||
{parsed.hashtags.length > 0 && (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{parsed.hashtags.slice(0, 3).map((tag) => (
|
||||
<Label key={tag} size="sm">
|
||||
{tag}
|
||||
</Label>
|
||||
))}
|
||||
{parsed.hashtags.length > 3 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
+{parsed.hashtags.length - 3}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</BaseEventContainer>
|
||||
);
|
||||
}
|
||||
@@ -48,6 +48,10 @@ import { ApplicationHandlerRenderer } from "./ApplicationHandlerRenderer";
|
||||
import { ApplicationHandlerDetailRenderer } from "./ApplicationHandlerDetailRenderer";
|
||||
import { HandlerRecommendationRenderer } from "./HandlerRecommendationRenderer";
|
||||
import { HandlerRecommendationDetailRenderer } from "./HandlerRecommendationDetailRenderer";
|
||||
import { CalendarDateEventRenderer } from "./CalendarDateEventRenderer";
|
||||
import { CalendarDateEventDetailRenderer } from "./CalendarDateEventDetailRenderer";
|
||||
import { CalendarTimeEventRenderer } from "./CalendarTimeEventRenderer";
|
||||
import { CalendarTimeEventDetailRenderer } from "./CalendarTimeEventDetailRenderer";
|
||||
import { NostrEvent } from "@/types/nostr";
|
||||
import { BaseEventContainer, type BaseEventProps } from "./BaseEventRenderer";
|
||||
|
||||
@@ -94,6 +98,8 @@ const kindRenderers: Record<number, React.ComponentType<BaseEventProps>> = {
|
||||
30618: RepositoryStateRenderer, // Repository State (NIP-34)
|
||||
30777: SpellbookRenderer, // Spellbook (Grimoire)
|
||||
30817: CommunityNIPRenderer, // Community NIP
|
||||
31922: CalendarDateEventRenderer, // Date-Based Calendar Event (NIP-52)
|
||||
31923: CalendarTimeEventRenderer, // Time-Based Calendar Event (NIP-52)
|
||||
31989: HandlerRecommendationRenderer, // Handler Recommendation (NIP-89)
|
||||
31990: ApplicationHandlerRenderer, // Application Handler (NIP-89)
|
||||
39701: Kind39701Renderer, // Web Bookmarks (NIP-B0)
|
||||
@@ -154,6 +160,8 @@ const detailRenderers: Record<
|
||||
30618: RepositoryStateDetailRenderer, // Repository State Detail (NIP-34)
|
||||
30777: SpellbookDetailRenderer, // Spellbook Detail (Grimoire)
|
||||
30817: CommunityNIPDetailRenderer, // Community NIP Detail
|
||||
31922: CalendarDateEventDetailRenderer, // Date-Based Calendar Event Detail (NIP-52)
|
||||
31923: CalendarTimeEventDetailRenderer, // Time-Based Calendar Event Detail (NIP-52)
|
||||
31989: HandlerRecommendationDetailRenderer, // Handler Recommendation Detail (NIP-89)
|
||||
31990: ApplicationHandlerDetailRenderer, // Application Handler Detail (NIP-89)
|
||||
};
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
BarChart3,
|
||||
Bookmark,
|
||||
Calendar,
|
||||
CalendarClock,
|
||||
CalendarDays,
|
||||
CheckCircle2,
|
||||
CircleDot,
|
||||
Cloud,
|
||||
@@ -1306,14 +1308,14 @@ export const EVENT_KINDS: Record<number | string, EventKind> = {
|
||||
name: "Calendar Event",
|
||||
description: "Date-Based Calendar Event",
|
||||
nip: "52",
|
||||
icon: Calendar,
|
||||
icon: CalendarDays,
|
||||
},
|
||||
31923: {
|
||||
kind: 31923,
|
||||
name: "Time Event",
|
||||
name: "Calendar Event",
|
||||
description: "Time-Based Calendar Event",
|
||||
nip: "52",
|
||||
icon: Calendar,
|
||||
icon: CalendarClock,
|
||||
},
|
||||
31924: {
|
||||
kind: 31924,
|
||||
|
||||
311
src/lib/calendar-event.ts
Normal file
311
src/lib/calendar-event.ts
Normal file
@@ -0,0 +1,311 @@
|
||||
import type { NostrEvent } from "@/types/nostr";
|
||||
import { getTagValue, getOrComputeCachedValue } from "applesauce-core/helpers";
|
||||
|
||||
/**
|
||||
* Participant in a calendar event (NIP-52)
|
||||
*/
|
||||
export interface CalendarParticipant {
|
||||
pubkey: string;
|
||||
relay?: string;
|
||||
role?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parsed Date-Based Calendar Event (kind 31922)
|
||||
*/
|
||||
export interface ParsedDateCalendarEvent {
|
||||
identifier: string;
|
||||
title: string;
|
||||
start: string; // YYYY-MM-DD
|
||||
end?: string; // YYYY-MM-DD (exclusive)
|
||||
description: string;
|
||||
locations: string[];
|
||||
geohash?: string;
|
||||
participants: CalendarParticipant[];
|
||||
hashtags: string[];
|
||||
references: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parsed Time-Based Calendar Event (kind 31923)
|
||||
*/
|
||||
export interface ParsedTimeCalendarEvent {
|
||||
identifier: string;
|
||||
title: string;
|
||||
start: number; // Unix timestamp
|
||||
end?: number; // Unix timestamp
|
||||
startTzid?: string; // IANA timezone identifier
|
||||
endTzid?: string; // IANA timezone identifier
|
||||
description: string;
|
||||
locations: string[];
|
||||
geohash?: string;
|
||||
participants: CalendarParticipant[];
|
||||
hashtags: string[];
|
||||
references: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Status of a calendar event relative to current time
|
||||
*/
|
||||
export type CalendarEventStatus = "upcoming" | "ongoing" | "past";
|
||||
|
||||
// Caching symbols for parsed calendar events
|
||||
const ParsedDateCalendarEventSymbol = Symbol("ParsedDateCalendarEvent");
|
||||
const ParsedTimeCalendarEventSymbol = Symbol("ParsedTimeCalendarEvent");
|
||||
|
||||
/**
|
||||
* Get all values for a given tag name
|
||||
*/
|
||||
function getTagValues(event: NostrEvent, tagName: string): string[] {
|
||||
return event.tags.filter((t) => t[0] === tagName).map((t) => t[1] || "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse participants from p tags
|
||||
* Format: ["p", <pubkey>, <relay>?, <role>?]
|
||||
*/
|
||||
function parseParticipants(event: NostrEvent): CalendarParticipant[] {
|
||||
return event.tags
|
||||
.filter((t) => t[0] === "p")
|
||||
.map((t) => ({
|
||||
pubkey: t[1],
|
||||
relay: t[2] || undefined,
|
||||
role: t[3] || undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a kind 31922 Date-Based Calendar Event
|
||||
* Results are cached on the event object for performance
|
||||
*/
|
||||
export function parseDateCalendarEvent(
|
||||
event: NostrEvent,
|
||||
): ParsedDateCalendarEvent {
|
||||
return getOrComputeCachedValue(event, ParsedDateCalendarEventSymbol, () => ({
|
||||
identifier: getTagValue(event, "d") || "",
|
||||
title: getTagValue(event, "title") || "",
|
||||
start: getTagValue(event, "start") || "",
|
||||
end: getTagValue(event, "end") || undefined,
|
||||
description: event.content || "",
|
||||
locations: getTagValues(event, "location"),
|
||||
geohash: getTagValue(event, "g") || undefined,
|
||||
participants: parseParticipants(event),
|
||||
hashtags: getTagValues(event, "t"),
|
||||
references: getTagValues(event, "r"),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a kind 31923 Time-Based Calendar Event
|
||||
* Results are cached on the event object for performance
|
||||
*/
|
||||
export function parseTimeCalendarEvent(
|
||||
event: NostrEvent,
|
||||
): ParsedTimeCalendarEvent {
|
||||
return getOrComputeCachedValue(event, ParsedTimeCalendarEventSymbol, () => {
|
||||
const startStr = getTagValue(event, "start");
|
||||
const endStr = getTagValue(event, "end");
|
||||
|
||||
return {
|
||||
identifier: getTagValue(event, "d") || "",
|
||||
title: getTagValue(event, "title") || "",
|
||||
start: startStr ? parseInt(startStr, 10) : 0,
|
||||
end: endStr ? parseInt(endStr, 10) : undefined,
|
||||
startTzid: getTagValue(event, "start_tzid") || undefined,
|
||||
endTzid: getTagValue(event, "end_tzid") || undefined,
|
||||
description: event.content || "",
|
||||
locations: getTagValues(event, "location"),
|
||||
geohash: getTagValue(event, "g") || undefined,
|
||||
participants: parseParticipants(event),
|
||||
hashtags: getTagValues(event, "t"),
|
||||
references: getTagValues(event, "r"),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status of a date-based calendar event
|
||||
*/
|
||||
export function getDateEventStatus(
|
||||
parsed: ParsedDateCalendarEvent,
|
||||
): CalendarEventStatus {
|
||||
if (!parsed.start) return "upcoming";
|
||||
|
||||
const now = new Date();
|
||||
const todayStr = now.toISOString().split("T")[0];
|
||||
|
||||
// Parse start date
|
||||
const startDate = parsed.start;
|
||||
|
||||
// Parse end date (exclusive) - if not provided, event is single day
|
||||
const endDate = parsed.end || parsed.start;
|
||||
|
||||
if (todayStr < startDate) {
|
||||
return "upcoming";
|
||||
} else if (todayStr >= endDate) {
|
||||
// End is exclusive, so if today >= end, event is past
|
||||
// But if no end provided, check if today > start
|
||||
if (!parsed.end && todayStr > startDate) {
|
||||
return "past";
|
||||
} else if (parsed.end) {
|
||||
return "past";
|
||||
}
|
||||
return "ongoing";
|
||||
} else {
|
||||
return "ongoing";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status of a time-based calendar event
|
||||
*/
|
||||
export function getTimeEventStatus(
|
||||
parsed: ParsedTimeCalendarEvent,
|
||||
): CalendarEventStatus {
|
||||
if (!parsed.start) return "upcoming";
|
||||
|
||||
const now = Date.now() / 1000;
|
||||
|
||||
if (now < parsed.start) {
|
||||
return "upcoming";
|
||||
} else if (parsed.end && now >= parsed.end) {
|
||||
return "past";
|
||||
} else if (!parsed.end && now > parsed.start + 3600) {
|
||||
// If no end time, consider past after 1 hour
|
||||
return "past";
|
||||
} else {
|
||||
return "ongoing";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a date string (YYYY-MM-DD) for display using locale
|
||||
*/
|
||||
export function formatDateForDisplay(
|
||||
dateStr: string,
|
||||
options?: Intl.DateTimeFormatOptions,
|
||||
): string {
|
||||
if (!dateStr) return "";
|
||||
|
||||
// Parse as local date (not UTC)
|
||||
const [year, month, day] = dateStr.split("-").map(Number);
|
||||
const date = new Date(year, month - 1, day);
|
||||
|
||||
return date.toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year:
|
||||
date.getFullYear() !== new Date().getFullYear() ? "numeric" : undefined,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a date range for display
|
||||
*/
|
||||
export function formatDateRange(start: string, end?: string): string {
|
||||
if (!start) return "";
|
||||
|
||||
const startFormatted = formatDateForDisplay(start);
|
||||
|
||||
if (!end || end === start) {
|
||||
return startFormatted;
|
||||
}
|
||||
|
||||
// Check if same month/year for compact display
|
||||
const [startYear, startMonth] = start.split("-");
|
||||
const [endYear, endMonth] = end.split("-");
|
||||
const endDay = end.split("-")[2];
|
||||
|
||||
if (startYear === endYear && startMonth === endMonth) {
|
||||
// Same month: "Jan 15-17"
|
||||
return `${startFormatted}–${parseInt(endDay, 10)}`;
|
||||
}
|
||||
|
||||
// Different months: "Jan 15 – Feb 2"
|
||||
const endFormatted = formatDateForDisplay(end);
|
||||
return `${startFormatted} – ${endFormatted}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a Unix timestamp for display using locale
|
||||
*/
|
||||
export function formatTimeForDisplay(
|
||||
timestamp: number,
|
||||
tzid?: string,
|
||||
options?: Intl.DateTimeFormatOptions,
|
||||
): string {
|
||||
if (!timestamp) return "";
|
||||
|
||||
const date = new Date(timestamp * 1000);
|
||||
|
||||
const defaultOptions: Intl.DateTimeFormatOptions = {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
year:
|
||||
date.getFullYear() !== new Date().getFullYear() ? "numeric" : undefined,
|
||||
timeZone: tzid || undefined,
|
||||
};
|
||||
|
||||
try {
|
||||
return date.toLocaleString(undefined, { ...defaultOptions, ...options });
|
||||
} catch {
|
||||
// Fallback if timezone is invalid
|
||||
return date.toLocaleString(undefined, {
|
||||
...defaultOptions,
|
||||
...options,
|
||||
timeZone: undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a time range for display
|
||||
*/
|
||||
export function formatTimeRange(
|
||||
start: number,
|
||||
end?: number,
|
||||
startTzid?: string,
|
||||
endTzid?: string,
|
||||
): string {
|
||||
if (!start) return "";
|
||||
|
||||
const startDate = new Date(start * 1000);
|
||||
const endDate = end ? new Date(end * 1000) : null;
|
||||
|
||||
// Check if same day
|
||||
const sameDay =
|
||||
endDate &&
|
||||
startDate.toDateString() === endDate.toDateString() &&
|
||||
startTzid === endTzid;
|
||||
|
||||
if (sameDay) {
|
||||
// Same day: "Jan 15, 7:00 PM – 9:00 PM"
|
||||
const dateStr = formatTimeForDisplay(start, startTzid, {
|
||||
hour: undefined,
|
||||
minute: undefined,
|
||||
});
|
||||
const startTime = startDate.toLocaleTimeString(undefined, {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
timeZone: startTzid || undefined,
|
||||
});
|
||||
const endTime = endDate.toLocaleTimeString(undefined, {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
timeZone: endTzid || startTzid || undefined,
|
||||
});
|
||||
return `${dateStr}, ${startTime} – ${endTime}`;
|
||||
}
|
||||
|
||||
const startFormatted = formatTimeForDisplay(start, startTzid);
|
||||
|
||||
if (!end) {
|
||||
return startFormatted;
|
||||
}
|
||||
|
||||
const endFormatted = formatTimeForDisplay(end, endTzid || startTzid);
|
||||
return `${startFormatted} – ${endFormatted}`;
|
||||
}
|
||||
Reference in New Issue
Block a user