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 SafeExternalLink from "../SafeExternalLink.astro";
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;}
const { atUri, uri, cid, name, startsAt, endsAt, location, mode = "inperson", description, source, sourceAvatar, showRsvp = false, isLoggedIn = false, rsvpStatus,} = Astro.props;
const anchorId = eventAnchorId(atUri);const DEFAULT_DURATION_MS = 2 * 60 * 60 * 1000;const computedEndsAt = endsAt ?? new Date(startsAt.getTime() + DEFAULT_DURATION_MS);const nowMs = Date.now();const isLive = startsAt.getTime() <= nowMs && nowMs < computedEndsAt.getTime();
const dateKeyFmt = new Intl.DateTimeFormat("en-CA", { year: "numeric", month: "2-digit", day: "2-digit",});const isToday = !isLive && startsAt.getTime() > nowMs && dateKeyFmt.format(startsAt) === dateKeyFmt.format(new Date(nowMs));
const formattedDate = startsAt.toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric", timeZone: "UTC",});const formattedTime = startsAt.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", 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 ? ( <img 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 >{formattedDate} · {formattedTime} UTC</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> { 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 ? ( <span class="event-card__login-hint">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--live { border-color: color-mix(in oklch, var(--primary) 55%, var(--border)); background: radial-gradient( circle at 100% 0%, color-mix(in oklch, var(--primary) 10%, var(--card)) 0%, var(--card) 65% ); } .event-card--live::before, .event-card--today::before { position: absolute; top: var(--space-sm); right: var(--space-sm); padding: 2px var(--space-sm); border-radius: var(--radius-pill); font-size: 10px; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; line-height: 1.4; } .event-card--live::before { content: "Live now"; background: var(--primary); color: var(--primary-foreground); } .event-card--today::before { content: "Today"; background: color-mix(in oklch, var(--primary) 14%, var(--card)); color: var(--primary); border: 1px solid color-mix(in oklch, var(--primary) 35%, var(--border)); }
.event-card:target { border-color: var(--primary); box-shadow: 0 0 0 3px color-mix(in oklch, var(--primary) 28%, transparent); animation: event-card-target-pulse 1.4s ease-out; } @keyframes event-card-target-pulse { 0% { box-shadow: 0 0 0 0 color-mix(in oklch, var(--primary) 45%, transparent); } 100% { box-shadow: 0 0 0 3px color-mix(in oklch, var(--primary) 28%, transparent); } } @media (prefers-reduced-motion: reduce) { .event-card:target { animation: none; } }
.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; 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); }
.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>