Something went wrong. Try again.
Website hub for the atmosphere community, aggregating posts, events, regional, and more
Something went wrong. Try again.
14 kB · 542 lines
Astro
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543---import { actions } from "astro:actions";import EventLocation from "./EventLocation.astro";import CheckIcon from "./icons/CheckIcon.astro";import ExternalLinkIcon from "./icons/ExternalLinkIcon.astro";import { eventAnchorId } from "../../lib/community/happening-now";import { smokeSignalHref } from "../../lib/community/events";import { effectiveEndsAt, eventTimingState,} from "../../lib/community/event-time";import { formatEventTime } from "../../lib/community/event-format";import SafeExternalLink from "../SafeExternalLink.astro";import { Image } from "astro:assets";
interface Props { atUri: string; uri: string; cid?: string; name: string; startsAt: Date; endsAt?: Date; location?: string; mode?: "inperson" | "virtual" | "hybrid"; description?: string; source?: string; sourceAvatar?: string; showRsvp?: boolean; isLoggedIn?: boolean; rsvpStatus?: string; unshareShareRecordUri?: string;}
const { atUri, uri, cid, name, startsAt, endsAt, location, mode = "inperson", description, source, sourceAvatar, showRsvp = false, isLoggedIn = false, rsvpStatus, unshareShareRecordUri,} = Astro.props;
const anchorId = eventAnchorId(atUri);const computedEndsAt = effectiveEndsAt({ startsAt, endsAt });
// We use UTC here so the markup doesn't depend on where this rendered. The viewer's own// timezone is applied in the browser by `applyEventCardTiming`, which also// re-decides what "today" means.const state = eventTimingState({ startsAt, endsAt }, { timeZone: "UTC" });const isLive = state === "live";const isToday = state === "today";
const formattedTime = formatEventTime(startsAt, { timeZone: "UTC" });
const sourceLabel = source ? `@${source.replace(/\.bsky\.social$/, "")}` : undefined;const titleSize = name.length <= 45 ? "lg" : name.length <= 70 ? "md" : "sm";const isGoing = rsvpStatus === "community.lexicon.calendar.rsvp#going";const isNotGoing = rsvpStatus === "community.lexicon.calendar.rsvp#notgoing";const hasPositiveRsvp = Boolean(rsvpStatus && !isNotGoing);const nextRsvpStatus = isGoing ? "notgoing" : "going";const rsvpButtonLabel = isGoing ? "I'm not going" : "I'm going";const rsvpButtonAriaLabel = isGoing ? `Mark yourself as not going to ${name}` : `Mark yourself as going to ${name}`;const canRsvp = showRsvp && isLoggedIn && Boolean(cid);const eventFallbackHref = smokeSignalHref(atUri);---
<article class:list={[ "event-card", "card", isLive && "event-card--live", isToday && "event-card--today", ]} id={anchorId} data-event-starts-at={startsAt.toISOString()} data-event-ends-at={computedEndsAt.toISOString()}> { sourceLabel && ( <header class="event-card__byline"> {sourceAvatar ? ( <Image class="event-card__source-avatar" src={sourceAvatar} alt="" width="14" height="14" loading="lazy" /> ) : ( <span class="event-card__source-avatar event-card__source-avatar--placeholder" aria-hidden="true" /> )} <span class="event-card__byline-label"> by{" "} <a class="event-card__source-handle" href={`https://bsky.app/profile/${source}`} rel="noopener noreferrer" target="_blank" > {sourceLabel} </a> </span> </header> ) } <p class="event-card__date"> <time datetime={startsAt.toISOString()} data-event-time >{formattedTime}</time > </p> <h3 class:list={["event-card__name", `event-card__name--${titleSize}`]}> <SafeExternalLink href={uri} fallbackHref={eventFallbackHref}> {name} </SafeExternalLink> </h3> <p class:list={["event-card__desc", !description && "event-card__desc--empty"]} > {description ?? "No description provided."} </p> <div class="event-card__footer"> <EventLocation mode={mode} location={location} /> <span class="badge badge--tag" data-rsvp-badge="saved" hidden={!(!isGoing && hasPositiveRsvp)}>Saved</span > </div> <div class="event-card__actions"> <SafeExternalLink href={uri} fallbackHref={eventFallbackHref} class="event-card__details" fallbackTag="none" announceNewTab > Details <ExternalLinkIcon class="event-card__details-icon" /> </SafeExternalLink> { unshareShareRecordUri && ( <form method="POST" action={actions.unshareAtmosphereContent} class="event-card__unshare-form" > <input type="hidden" name="shareRecordUri" value={unshareShareRecordUri} /> <button class="event-card__unshare-button" type="submit"> Unshare </button> </form> ) } { canRsvp ? ( <form method="post" action={actions.rsvpEvent} class="event-card__rsvp-form" data-rsvp-form > <input type="hidden" name="eventUri" value={atUri} /> <input type="hidden" name="eventCid" value={cid} /> <input type="hidden" name="eventName" value={name} /> <input type="hidden" name="status" value={nextRsvpStatus} data-rsvp-status-input /> <button class:list={[ "event-card__rsvp", isGoing && "event-card__rsvp--cancel", ]} type="submit" aria-label={rsvpButtonAriaLabel} data-rsvp-button data-event-name={name} > {rsvpButtonLabel} </button> <span class="event-card__going" data-rsvp-badge="going" hidden={!isGoing} > <CheckIcon /> You're going </span> </form> ) : showRsvp ? ( // Logged in but still can't RSVP means we couldn't resolve the event's // canonical cid, so don't tell them to log in — they already are. <span class="event-card__login-hint"> {isLoggedIn ? "You cannot RSVP to this event" : "Log in above to RSVP"} </span> ) : isGoing ? ( <span class="event-card__going"> <CheckIcon /> You're going </span> ) : null } </div> { canRsvp && ( <p class="event-card__rsvp-message" data-rsvp-message hidden role="status" aria-live="polite" /> ) }</article>
<style> .event-card { position: relative; padding: var(--space-base) var(--space-lg); transition: border-color 0.15s ease, box-shadow 0.2s ease, background 0.2s ease; display: flex; flex-direction: column; height: 100%; scroll-margin-top: calc(var(--space-4xl) + var(--space-lg)); } .event-card:hover { border-color: var(--card-border-hover); }
.event-card__date { font-size: var(--text-sm); font-weight: 700; color: var(--primary); text-transform: uppercase; letter-spacing: 0.04em; }
.event-card__name { font-family: var(--font-body); font-weight: 700; color: var(--card-foreground); margin-top: var(--space-xs); line-height: var(--leading-tight); text-wrap: balance; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; } .event-card__name--lg { font-size: var(--text-xl); } .event-card__name--md { font-size: var(--text-lg); } .event-card__name--sm { font-size: var(--text-base); } .event-card__name a { color: inherit; text-decoration: none; } .event-card__name a:hover { color: var(--primary); }
.event-card__desc { font-size: var(--text-xs); color: var(--foreground-muted); margin-top: var(--space-sm); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; line-height: var(--leading-normal); min-height: 2lh; } .event-card__desc--empty { color: var(--foreground-subtle); font-style: italic; }
.event-card__footer { margin-top: var(--space-md); display: flex; flex-wrap: wrap; align-items: flex-start; align-content: flex-start; column-gap: var(--space-md); row-gap: var(--space-xs); flex-grow: 1; }
.event-card__byline { display: flex; align-items: center; gap: 6px; margin-bottom: var(--space-sm); min-height: 18px; } .event-card__byline-label { font-size: var(--text-xs); color: var(--foreground-secondary); line-height: 1; } .event-card__source-handle { font-weight: 600; color: var(--foreground-secondary); text-decoration: none; } a.event-card__source-handle:hover { color: var(--primary); text-decoration: underline; text-underline-offset: 2px; } .event-card__source-avatar { flex-shrink: 0; width: 14px; height: 14px; border-radius: 50%; object-fit: cover; background: var(--muted); opacity: 0.85; } .event-card__source-avatar--placeholder { display: inline-block; }
.event-card__going { display: inline-flex; align-items: center; gap: 6px; padding: var(--space-sm) var(--space-md); border-radius: var(--radius-pill); background: var(--success-muted); color: var(--success-muted-foreground); font-size: var(--text-sm); font-weight: 600; line-height: 1; }
.event-card__actions { margin-top: var(--space-md); padding-top: var(--space-md); border-top: 1px solid var(--border); display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--space-sm); }
.event-card__details, .event-card__rsvp, .event-card__login-hint { min-height: 36px; display: inline-flex; align-items: center; justify-content: center; border-radius: var(--radius-sm); font: inherit; font-size: var(--text-sm); font-weight: 700; line-height: 1; white-space: nowrap; }
.event-card__details { color: var(--primary); gap: 4px; } .event-card__details-icon { flex-shrink: 0; opacity: 0.7; transition: opacity 0.15s ease; } .event-card__details:hover .event-card__details-icon { opacity: 1; }
.event-card__rsvp-form { margin: 0; display: inline-flex; align-items: center; gap: var(--space-md); margin-left: auto; }
.event-card__unshare-form { margin: 0; }
.event-card__unshare-button { display: inline-flex; align-items: center; justify-content: center; min-height: 32px; border: 1px solid color-mix(in oklch, var(--primary) 24%, var(--border)); border-radius: var(--radius-sm); padding: var(--space-xs) var(--space-sm); background: transparent; color: var(--primary); font: inherit; font-size: var(--text-xs); font-weight: 700; cursor: pointer; transition: background 0.15s ease, border-color 0.15s ease; }
.event-card__unshare-button:hover { background: color-mix(in oklch, var(--primary) 10%, transparent); border-color: color-mix(in oklch, var(--primary) 40%, var(--border)); }
.event-card__unshare-button:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
.event-card__rsvp { min-width: 80px; border: 0; padding: var(--space-sm) var(--space-base); background: var(--primary); color: var(--primary-foreground); cursor: pointer; transition: background 0.15s ease; } .event-card__rsvp:hover { background: var(--primary-hover); }
.event-card__rsvp--cancel { min-width: 0; min-height: 0; padding: 0; background: transparent; color: var(--error); font-size: var(--text-xs); font-weight: 600; text-decoration: underline; text-decoration-color: oklch(from var(--error) l c h / 0.4); text-underline-offset: 3px; } .event-card__rsvp--cancel:hover { background: transparent; color: var(--error); text-decoration-color: currentColor; }
.event-card__login-hint { color: var(--foreground-muted); font-weight: 600; }
.event-card__rsvp-message { position: absolute; top: var(--space-sm); left: 50%; transform: translateX(-50%); margin: 0; padding: var(--space-xs) var(--space-sm); border-radius: var(--radius-sm); font-size: var(--text-xs); font-weight: 600; line-height: var(--leading-normal); box-shadow: var(--elevation-regular); max-width: calc(100% - 2 * var(--space-sm)); white-space: nowrap; z-index: var(--z-feedback); animation: event-card-toast-in 0.18s ease-out; } @keyframes event-card-toast-in { from { opacity: 0; transform: translate(-50%, -4px); } to { opacity: 1; transform: translate(-50%, 0); } } @media (prefers-reduced-motion: reduce) { .event-card__rsvp-message { animation: none; } } .event-card__rsvp-message[data-tone="success"] { background: var(--success-muted); color: var(--success-muted-foreground); } .event-card__rsvp-message[data-tone="neutral"] { background: var(--info-muted); color: var(--info-muted-foreground); } .event-card__rsvp-message[data-tone="error"] { background: var(--error-muted); color: var(--error-muted-foreground); }
.event-card__rsvp[disabled] { cursor: progress; opacity: 0.7; }</style>
<script> import { bindRsvpForms } from "../../features/event-rsvp/client";
bindRsvpForms();</script>