Something went wrong. Try again.
Website hub for the atmosphere community, aggregating posts, events, regional, and more
Something went wrong. Try again.
15 kB · 560 lines
Astro
at mega-cleanup
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561---import { actions } from "astro:actions";import EventLocation from "./EventLocation.astro";import CheckIcon from "./icons/CheckIcon.astro";import ExternalLinkIcon from "./icons/ExternalLinkIcon.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, location, mode = "inperson", description, source, sourceAvatar, showRsvp = false, isLoggedIn = false, rsvpStatus,} = Astro.props;
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);---
<article class="event-card card"> { 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}`]}> <a href={uri} rel="noopener noreferrer" target="_blank">{name}</a> </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"> <a href={uri} class="event-card__details" rel="noopener noreferrer" target="_blank" > Details <ExternalLinkIcon class="event-card__details-icon" /> <span class="visually-hidden"> (opens in new tab)</span> </a> { 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="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; display: flex; flex-direction: column; height: 100%; } .event-card:hover { border-color: var(--blue-300); }
.event-card__date { font-size: var(--text-sm); font-weight: 700; color: var(--color-primary); text-transform: uppercase; letter-spacing: 0.04em; }
.event-card__name { font-family: var(--font-body); font-weight: 700; color: var(--neutral-900); 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(--color-primary); }
.event-card__desc { font-size: var(--text-xs); color: var(--color-text-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(--neutral-400); 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(--color-text-secondary); line-height: 1; } .event-card__source-handle { font-weight: 600; color: var(--color-text-secondary); text-decoration: none; } a.event-card__source-handle:hover { color: var(--color-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(--neutral-100); 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(--teal-100); color: var(--teal-600); 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(--color-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(--color-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(--color-primary); color: var(--color-text-on-primary); cursor: pointer; transition: background 0.15s ease; } .event-card__rsvp:hover { background: var(--color-primary-hover); }
.event-card__rsvp--cancel { min-width: 0; min-height: 0; padding: 0; background: transparent; color: var(--coral-600); font-size: var(--text-xs); font-weight: 600; text-decoration: underline; text-decoration-color: oklch(from var(--coral-600) l c h / 0.4); text-underline-offset: 3px; } .event-card__rsvp--cancel:hover { background: transparent; color: var(--coral-600); text-decoration-color: currentColor; }
.event-card__login-hint { color: var(--color-text-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: 0 4px 12px oklch(0% 0 0 / 0.15); max-width: calc(100% - 2 * var(--space-sm)); white-space: nowrap; z-index: 5; 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(--teal-100); color: var(--teal-600); } .event-card__rsvp-message[data-tone="neutral"] { background: var(--blue-100); color: var(--blue-700, var(--color-primary)); } .event-card__rsvp-message[data-tone="error"] { background: var(--coral-100); color: var(--coral-600); }
.event-card__rsvp[disabled] { cursor: progress; opacity: 0.7; }</style>
<script> import { actions } from "astro:actions";
const RSVP_GOING = "community.lexicon.calendar.rsvp#going"; const RSVP_NOT_GOING = "community.lexicon.calendar.rsvp#notgoing";
let toastTimeout: ReturnType<typeof setTimeout> | undefined; function showToast( el: HTMLElement, text: string, tone: "success" | "neutral" | "error", ) { document .querySelectorAll<HTMLElement>("[data-rsvp-message]") .forEach((m) => { if (m !== el) m.hidden = true; }); el.textContent = text; el.dataset.tone = tone; el.hidden = false; if (toastTimeout) clearTimeout(toastTimeout); if (tone !== "error") { toastTimeout = setTimeout(() => { el.hidden = true; }, 3500); } }
document .querySelectorAll<HTMLFormElement>("form[data-rsvp-form]") .forEach((form) => { form.addEventListener("submit", async (event) => { event.preventDefault();
const card = form.closest<HTMLElement>(".event-card"); const button = form.querySelector<HTMLButtonElement>("[data-rsvp-button]"); const statusInput = form.querySelector<HTMLInputElement>( "[data-rsvp-status-input]", ); const message = card?.querySelector<HTMLElement>("[data-rsvp-message]"); const goingBadge = card?.querySelector<HTMLElement>( '[data-rsvp-badge="going"]', ); const savedBadge = card?.querySelector<HTMLElement>( '[data-rsvp-badge="saved"]', ); if (!card || !button || !statusInput) return;
const formData = new FormData(form); const submittedStatus = formData.get("status"); const eventName = button.dataset.eventName ?? "";
button.disabled = true; if (message) message.hidden = true;
const { data, error } = await actions.rsvpEvent(formData);
button.disabled = false;
if (error || !data) { if (message) { showToast(message, "Couldn't save your RSVP. Try again.", "error"); } return; }
if ( data.redirectUrl?.includes("rsvp=error") || data.redirectUrl?.includes("rsvp=permission") ) { if (message) { showToast( message, data.redirectUrl.includes("rsvp=permission") ? "Your login needs RSVP permission. Log out and back in, then try again." : "Couldn't save your RSVP. Try again.", "error", ); } return; }
const nowGoing = submittedStatus === "going"; card.dataset.rsvpStatus = nowGoing ? RSVP_GOING : RSVP_NOT_GOING;
if (goingBadge) goingBadge.hidden = !nowGoing; if (savedBadge) savedBadge.hidden = true;
button.textContent = nowGoing ? "I'm not going" : "I'm going"; button.classList.toggle("event-card__rsvp--cancel", nowGoing); button.setAttribute( "aria-label", nowGoing ? `Mark yourself as not going to ${eventName}` : `Mark yourself as going to ${eventName}`, ); statusInput.value = nowGoing ? "notgoing" : "going";
if (message) { const text = nowGoing ? eventName ? `You're going to ${eventName}.` : "You're marked as going." : eventName ? `You're no longer going to ${eventName}.` : "You're no longer marked as going."; showToast(message, text, nowGoing ? "success" : "neutral"); } }); });</script>