Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 44 additions & 2 deletions src/components/Events.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import { useContext, useEffect, useState } from "react";
import { useContext, useEffect, useRef, useState } from "react";
import { Dialog, DialogContent, DialogActions } from "@mui/material";
import { hasMeaningfulDescription, primaryEventLink, type EventItem } from "../store/eventsClient";
import {
displayTitle,
getUpcomingEvents,
groupIntoSections,
isEventPast,
type EventSection,
type UpcomingEvent,
} from "../utils/eventSections";
import { copyAnchorLink } from "../utils/copyAnchorLink";
import { copyAnchorLink, copyEventLink } from "../utils/copyAnchorLink";
import {
formatSpeakerList,
getDescriptionExcerpt,
Expand All @@ -22,6 +23,7 @@ import {
ChevronDown,
CloseIcon,
EventModalContext,
ShareIcon,
useCarouselScroll,
useEventDate,
type SelectedEvent,
Expand Down Expand Up @@ -171,6 +173,14 @@ function EventDetailsDialogBody({
} = useEventDate(event);
const { link: infoLink, label: infoLabel } = primaryEventLink(event, isPast);
const title = displayTitle(event);
const [copied, setCopied] = useState(false);

const handleCopyLink = async () => {
const ok = await copyEventLink(event.id);
if (!ok) return;
setCopied(true);
window.setTimeout(() => setCopied(false), COPIED_FEEDBACK_MS);
};

// MUI Dialog is kept for its focus trap, escape handling, and scroll lock;
// everything inside the paper is plain markup.
Expand All @@ -194,6 +204,24 @@ function EventDetailsDialogBody({
alt={title}
className="max-h-[70vh] w-auto max-w-full object-contain"
/>
<span className="absolute right-16 top-3 inline-flex">
<button
type="button"
onClick={handleCopyLink}
aria-label="Copy link to this event"
className="inline-flex h-11 w-11 items-center justify-center rounded-full bg-white text-gray-700 shadow-sm transition-colors duration-150 hover:bg-[#EA4335] hover:text-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#EA4335]"
>
<ShareIcon />
</button>
{copied && (
<span
role="status"
className={`absolute -top-9 left-1/2 -translate-x-1/2 whitespace-nowrap rounded-full bg-gray-900 px-3 py-1 text-white ${TS_CAPTION_SIZE}`}
>
Copied!
</span>
)}
</span>
<button
type="button"
onClick={onClose}
Expand Down Expand Up @@ -614,6 +642,7 @@ export default function Events({
const [events, setEvents] = useState<EventItem[]>(initialEvents);
const [loading, setLoading] = useState(initialLoading);
const [selectedEvent, setSelectedEvent] = useState<SelectedEvent | null>(null);
const deepLinkHandled = useRef(false);

const fetchEvents = async () => {
setLoading(true);
Expand Down Expand Up @@ -660,6 +689,19 @@ export default function Events({
document.getElementById(hash)?.scrollIntoView({ block: "start" });
}, [loading, events.length]);

// A "Copy link" button in the event modal encodes the event's id as
// ?event=<id> (see copyEventLink) — re-open that event's modal on load so
// the link is actually shareable, not just a URL that looks specific.
useEffect(() => {
if (loading || events.length === 0 || deepLinkHandled.current) return;
deepLinkHandled.current = true;
const id = new URLSearchParams(window.location.search).get("event");
if (!id) return;
const event = events.find((e) => e.id === id);
if (!event) return;
setSelectedEvent({ event, isPast: isEventPast(event) });
}, [loading, events.length]);

const sections = groupIntoSections(events);
const hasPastEvents = sections.some((section) => section.past.length > 0);

Expand Down
33 changes: 31 additions & 2 deletions src/components/events/EventModal.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { primaryEventLink } from "../../store/eventsClient";
import { displayTitle } from "../../utils/eventSections";
import { parseEventDescription, renderInlineText } from "../../utils/eventDescription";
import { useBodyScrollLock } from "../../utils/useBodyScrollLock";
import { ArrowRight, CloseIcon, useEventDate, type SelectedEvent } from "./eventsShared";
import { copyEventLink } from "../../utils/copyAnchorLink";
import { ArrowRight, CloseIcon, ShareIcon, useEventDate, type SelectedEvent } from "./eventsShared";
import { EventPreviewImage } from "./EventPreviewImage";

const COPIED_FEEDBACK_MS = 1500;

function EventDescription({ text }: { text: string }) {
const blocks = parseEventDescription(text);

Expand Down Expand Up @@ -51,9 +54,17 @@ export function EventModal({ selected, onClose }: EventModalProps) {
} = useEventDate(event);
const { link: infoLink, label: infoLabel } = primaryEventLink(event, isPast);
const title = displayTitle(event);
const [copied, setCopied] = useState(false);

useBodyScrollLock(true);

const handleCopyLink = async () => {
const ok = await copyEventLink(event.id);
if (!ok) return;
setCopied(true);
window.setTimeout(() => setCopied(false), COPIED_FEEDBACK_MS);
};

useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
Expand Down Expand Up @@ -91,6 +102,24 @@ export function EventModal({ selected, onClose }: EventModalProps) {
alt={title}
className="max-h-[70vh] w-auto max-w-full object-contain"
/>
<span className="absolute right-16 top-4 inline-flex">
<button
type="button"
onClick={handleCopyLink}
aria-label="Copy link to this event"
className="inline-flex h-10 w-10 items-center justify-center rounded-full bg-page text-ink transition-colors duration-150 hover:bg-brand hover:text-page focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand"
>
<ShareIcon />
</button>
{copied && (
<span
role="status"
className="ts-caption absolute -top-8 left-1/2 -translate-x-1/2 whitespace-nowrap rounded-pill bg-ink px-2.5 py-1 text-page"
>
Copied!
</span>
)}
</span>
<button
type="button"
onClick={onClose}
Expand Down
18 changes: 16 additions & 2 deletions src/components/events/EventsNew.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import type { EventItem } from "../../store/eventsClient";
import { groupIntoSections } from "../../utils/eventSections";
import { groupIntoSections, isEventPast } from "../../utils/eventSections";
import {
EventModalContext,
useVisitorZoneReady,
Expand Down Expand Up @@ -46,6 +46,7 @@ export default function EventsNew({ initialEvents = [] }: EventsNewProps) {
const [events, setEvents] = useState<EventItem[]>(initialEvents);
const [loading, setLoading] = useState(initialEvents.length === 0);
const [selectedEvent, setSelectedEvent] = useState<SelectedEvent | null>(null);
const deepLinkHandled = useRef(false);
// This island is mounted `client:load`, so it server-renders first. Event
// times only become the visitor's own after mount — see VisitorZoneContext.
const visitorZoneReady = useVisitorZoneReady();
Expand Down Expand Up @@ -73,6 +74,19 @@ export default function EventsNew({ initialEvents = [] }: EventsNewProps) {
target.scrollIntoView({ block: "start" });
}, [loading]);

// A "Copy link" button in the event modal encodes the event's id as
// ?event=<id> (see copyEventLink) — re-open that event's modal on load so
// the link is actually shareable, not just a URL that looks specific.
useEffect(() => {
if (loading || events.length === 0 || deepLinkHandled.current) return;
deepLinkHandled.current = true;
const id = new URLSearchParams(window.location.search).get("event");
if (!id) return;
const event = events.find((e) => e.id === id);
if (!event) return;
setSelectedEvent({ event, isPast: isEventPast(event) });
}, [loading, events.length]);

const sections = groupIntoSections(events);
const hasPastEvents = sections.some((section) => section.past.length > 0);

Expand Down
22 changes: 22 additions & 0 deletions src/components/events/eventsShared.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,28 @@ export function ChevronDown({ size = 18, expanded }: { size?: number; expanded:
);
}

export function ShareIcon({ size = 18 }: { size?: number }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<circle cx="18" cy="5" r="3" />
<circle cx="6" cy="12" r="3" />
<circle cx="18" cy="19" r="3" />
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49" />
<line x1="15.41" y1="6.51" x2="8.59" y2="10.49" />
</svg>
);
}

export function CloseIcon({ size = 18 }: { size?: number }) {
return (
<svg
Expand Down
17 changes: 17 additions & 0 deletions src/utils/copyAnchorLink.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,20 @@ export async function copyAnchorLink(slug: string): Promise<boolean> {
return false;
}
}

// Same pattern, but for a single event rather than a whole category: encodes
// the event's (ICS UID) id as a `?event=` query param so the URL round-trips
// through EventItem.id lookups elsewhere (see isEventPast/groupIntoSections
// callers reading this param back out on mount).
export async function copyEventLink(id: string): Promise<boolean> {
const search = `?event=${encodeURIComponent(id)}`;
const url = `${window.location.origin}${window.location.pathname}${search}`;
history.replaceState(null, "", `${window.location.pathname}${search}`);

try {
await navigator.clipboard.writeText(url);
return true;
} catch {
return false;
}
}
7 changes: 7 additions & 0 deletions src/utils/eventSections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,3 +181,10 @@ export function groupIntoSections(events: EventItem[], nowMs: number = Date.now(
function timeOf(event: EventItem): number {
return event.dateUtcIso ? new Date(event.dateUtcIso).getTime() : 0;
}

// Mirrors the past/upcoming split used when building sections, for callers
// (e.g. a deep-linked event) that need to classify a single event on its own
// rather than via groupIntoSections/getUpcomingEvents.
export function isEventPast(event: EventItem, nowMs: number = Date.now()): boolean {
return timeOf(event) < nowMs;
}
Loading