feat: calendar
This commit is contained in:
parent
edc0cfdb0f
commit
caab3ab187
2 changed files with 513 additions and 0 deletions
213
src/pages/calendar.astro
Normal file
213
src/pages/calendar.astro
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
---
|
||||
import { getCollection } from 'astro:content';
|
||||
import AppLayout from '../layouts/AppLayout.astro';
|
||||
import { fmtDate } from '../lib/markdown';
|
||||
|
||||
const user = Astro.locals.user;
|
||||
|
||||
const allMeetings = await getCollection('meetings');
|
||||
const meetings = allMeetings.sort(
|
||||
(a, b) => new Date(a.data.date).getTime() - new Date(b.data.date).getTime()
|
||||
);
|
||||
|
||||
const now = new Date();
|
||||
const upcoming = meetings.filter((m) => new Date(m.data.date) >= now);
|
||||
const past = meetings.filter((m) => new Date(m.data.date) < now).reverse();
|
||||
---
|
||||
<AppLayout title="Calendar" user={user}>
|
||||
<div class="page">
|
||||
|
||||
<header class="page-header">
|
||||
<p class="label-sm eyebrow">Calendar</p>
|
||||
<h1 class="display-md page-title">Meetings and sessions.</h1>
|
||||
<p class="lead subtitle">
|
||||
CAB sessions, demos, and reviews. Agendas posted two weeks before;
|
||||
notes within 48 hours.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="content">
|
||||
{upcoming.length > 0 && (
|
||||
<section class="meeting-section">
|
||||
<h2 class="label-sm section-heading">Upcoming</h2>
|
||||
<ul class="meeting-list">
|
||||
{upcoming.map((meeting) => (
|
||||
<li class="meeting-item">
|
||||
<a href={`/calendar/${meeting.slug}`} class="meeting-link">
|
||||
<div class="meeting-meta">
|
||||
<time class="label-sm meeting-date" datetime={String(meeting.data.date)}>
|
||||
{fmtDate(String(meeting.data.date))}
|
||||
</time>
|
||||
<span class="label-sm meeting-time">{meeting.data.time}</span>
|
||||
</div>
|
||||
<div class="meeting-info">
|
||||
<h3 class="headline-sm meeting-title">{meeting.data.title}</h3>
|
||||
<p class="body-sm meeting-location">{meeting.data.location}</p>
|
||||
</div>
|
||||
<span class="meeting-arrow" aria-hidden="true">↗</span>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{past.length > 0 && (
|
||||
<section class="meeting-section">
|
||||
<h2 class="label-sm section-heading">Past</h2>
|
||||
<ul class="meeting-list">
|
||||
{past.map((meeting) => (
|
||||
<li class="meeting-item past">
|
||||
<a href={`/calendar/${meeting.slug}`} class="meeting-link">
|
||||
<div class="meeting-meta">
|
||||
<time class="label-sm meeting-date" datetime={String(meeting.data.date)}>
|
||||
{fmtDate(String(meeting.data.date))}
|
||||
</time>
|
||||
<span class="label-sm meeting-time">{meeting.data.time}</span>
|
||||
</div>
|
||||
<div class="meeting-info">
|
||||
<h3 class="headline-sm meeting-title">{meeting.data.title}</h3>
|
||||
<p class="body-sm meeting-location">{meeting.data.location}</p>
|
||||
</div>
|
||||
<span class="meeting-arrow" aria-hidden="true">↗</span>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</AppLayout>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
padding: var(--space-12) var(--space-20) var(--space-16);
|
||||
max-width: var(--content-max);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* ── Header ──────────────────────────────────────────────────────── */
|
||||
.page-header {
|
||||
max-width: 44rem;
|
||||
margin-bottom: var(--space-12);
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
letter-spacing: var(--tracking-wider);
|
||||
text-transform: uppercase;
|
||||
color: var(--on-surface-muted);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin-bottom: var(--space-5);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--on-surface-variant);
|
||||
max-width: var(--reading-max);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── Content ─────────────────────────────────────────────────────── */
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-12);
|
||||
max-width: 52rem;
|
||||
}
|
||||
|
||||
/* ── Section ─────────────────────────────────────────────────────── */
|
||||
.section-heading {
|
||||
letter-spacing: var(--tracking-wider);
|
||||
text-transform: uppercase;
|
||||
color: var(--on-surface-muted);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
/* ── Meeting list ────────────────────────────────────────────────── */
|
||||
.meeting-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.meeting-item {
|
||||
border-top: var(--ghost-border);
|
||||
}
|
||||
.meeting-item:last-child {
|
||||
border-bottom: var(--ghost-border);
|
||||
}
|
||||
|
||||
.meeting-link {
|
||||
display: grid;
|
||||
grid-template-columns: 10rem 1fr auto;
|
||||
align-items: center;
|
||||
gap: var(--space-5);
|
||||
padding: var(--space-5) 0;
|
||||
text-decoration: none;
|
||||
border-bottom: none;
|
||||
transition: background var(--duration-fast) var(--ease-standard);
|
||||
}
|
||||
.meeting-link:hover {
|
||||
border-bottom: none;
|
||||
}
|
||||
.meeting-link:hover .meeting-title {
|
||||
color: var(--secondary);
|
||||
}
|
||||
.meeting-link:hover .meeting-arrow {
|
||||
color: var(--secondary);
|
||||
}
|
||||
|
||||
.meeting-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.meeting-date {
|
||||
letter-spacing: var(--tracking-wide);
|
||||
text-transform: uppercase;
|
||||
color: var(--on-surface-variant);
|
||||
}
|
||||
|
||||
.meeting-time {
|
||||
color: var(--on-surface-muted);
|
||||
letter-spacing: var(--tracking-normal);
|
||||
}
|
||||
|
||||
.meeting-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.meeting-title {
|
||||
font-family: var(--font-serif);
|
||||
font-weight: 400;
|
||||
letter-spacing: var(--tracking-snug);
|
||||
margin: 0;
|
||||
color: var(--on-surface);
|
||||
transition: color var(--duration-fast) var(--ease-standard);
|
||||
}
|
||||
|
||||
.meeting-location {
|
||||
color: var(--on-surface-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.meeting-arrow {
|
||||
font-family: var(--font-serif);
|
||||
color: var(--on-surface-muted);
|
||||
transition: color var(--duration-fast) var(--ease-standard);
|
||||
}
|
||||
|
||||
.past .meeting-title {
|
||||
color: var(--on-surface-variant);
|
||||
}
|
||||
.past .meeting-date {
|
||||
color: var(--on-surface-muted);
|
||||
}
|
||||
</style>
|
||||
300
src/pages/calendar/[slug].astro
Normal file
300
src/pages/calendar/[slug].astro
Normal file
|
|
@ -0,0 +1,300 @@
|
|||
---
|
||||
import { getCollection, getEntry } from 'astro:content';
|
||||
import AppLayout from '../../layouts/AppLayout.astro';
|
||||
import { fmtDate, renderMd } from '../../lib/markdown';
|
||||
import { getAttendanceSummary, getUserAttendance, setAttendance } from '../../lib/db';
|
||||
|
||||
const user = Astro.locals.user;
|
||||
const { slug } = Astro.params;
|
||||
|
||||
const meeting = await getEntry('meetings', slug as string);
|
||||
if (!meeting) return Astro.redirect('/calendar');
|
||||
|
||||
const isPast = new Date(meeting.data.date) < new Date();
|
||||
|
||||
// Handle attendance POST
|
||||
if (Astro.request.method === 'POST') {
|
||||
const data = await Astro.request.formData();
|
||||
const status = data.get('status') as 'yes' | 'no' | null;
|
||||
if (status === 'yes' || status === 'no') {
|
||||
setAttendance(user.id, meeting.slug, status);
|
||||
}
|
||||
return Astro.redirect(`/calendar/${slug}`);
|
||||
}
|
||||
|
||||
const { Content } = await meeting.render();
|
||||
const attendance = getAttendanceSummary(meeting.slug);
|
||||
const myStatus = getUserAttendance(user.id, meeting.slug);
|
||||
---
|
||||
<AppLayout title={meeting.data.title} user={user}>
|
||||
<div class="page">
|
||||
|
||||
<nav class="breadcrumb" aria-label="Breadcrumb">
|
||||
<a href="/calendar" class="crumb label-sm">Calendar</a>
|
||||
<span class="crumb-sep" aria-hidden="true">›</span>
|
||||
<span class="crumb label-sm crumb-current">{meeting.data.title}</span>
|
||||
</nav>
|
||||
|
||||
<div class="layout">
|
||||
<article class="article">
|
||||
<header class="meeting-header">
|
||||
<time class="label-sm meeting-date" datetime={String(meeting.data.date)}>
|
||||
{fmtDate(String(meeting.data.date))}
|
||||
</time>
|
||||
<h1 class="display-md meeting-title">{meeting.data.title}</h1>
|
||||
<dl class="meta-list">
|
||||
<div class="meta-row">
|
||||
<dt class="label-sm meta-label">Time</dt>
|
||||
<dd class="body-md meta-value">{meeting.data.time}</dd>
|
||||
</div>
|
||||
<div class="meta-row">
|
||||
<dt class="label-sm meta-label">Location</dt>
|
||||
<dd class="body-md meta-value">{meeting.data.location}</dd>
|
||||
</div>
|
||||
{meeting.data.attendees && (
|
||||
<div class="meta-row">
|
||||
<dt class="label-sm meta-label">Attendees</dt>
|
||||
<dd class="body-md meta-value">{meeting.data.attendees}</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
</header>
|
||||
|
||||
<div class="prose">
|
||||
<Content />
|
||||
</div>
|
||||
</article>
|
||||
|
||||
{!isPast && (
|
||||
<aside class="attendance-sidebar">
|
||||
<div class="attendance-card">
|
||||
<h2 class="label-sm attendance-heading">Will you attend?</h2>
|
||||
|
||||
<form method="POST" class="attendance-form">
|
||||
<button
|
||||
type="submit"
|
||||
name="status"
|
||||
value="yes"
|
||||
class:list={['attend-btn', { active: myStatus === 'yes' }]}
|
||||
>
|
||||
Yes, I will be there
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
name="status"
|
||||
value="no"
|
||||
class:list={['attend-btn attend-btn-no', { active: myStatus === 'no' }]}
|
||||
>
|
||||
No, I cannot make it
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="attendance-tally">
|
||||
<span class="label-sm tally-yes">{attendance.yes} attending</span>
|
||||
{attendance.no > 0 && (
|
||||
<span class="label-sm tally-no">{attendance.no} not attending</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</AppLayout>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
padding: var(--space-10) var(--space-20) var(--space-16);
|
||||
max-width: var(--content-max);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* ── Breadcrumb ──────────────────────────────────────────────────── */
|
||||
.breadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-8);
|
||||
}
|
||||
|
||||
.crumb {
|
||||
color: var(--on-surface-muted);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
text-transform: uppercase;
|
||||
text-decoration: none;
|
||||
border-bottom: none;
|
||||
transition: color var(--duration-fast) var(--ease-standard);
|
||||
}
|
||||
a.crumb:hover {
|
||||
color: var(--on-surface-variant);
|
||||
border-bottom: none;
|
||||
}
|
||||
.crumb-sep { color: var(--on-surface-muted); }
|
||||
.crumb-current { color: var(--on-surface-variant); }
|
||||
|
||||
/* ── Layout ──────────────────────────────────────────────────────── */
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 18rem;
|
||||
gap: var(--space-10);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
/* ── Article ─────────────────────────────────────────────────────── */
|
||||
.meeting-header {
|
||||
margin-bottom: var(--space-10);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.meeting-date {
|
||||
letter-spacing: var(--tracking-wide);
|
||||
text-transform: uppercase;
|
||||
color: var(--on-surface-muted);
|
||||
}
|
||||
|
||||
.meeting-title {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.meta-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.meta-row {
|
||||
display: flex;
|
||||
gap: var(--space-4);
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.meta-label {
|
||||
letter-spacing: var(--tracking-wide);
|
||||
text-transform: uppercase;
|
||||
color: var(--on-surface-muted);
|
||||
min-width: 5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.meta-value {
|
||||
color: var(--on-surface-variant);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── Prose ───────────────────────────────────────────────────────── */
|
||||
.prose :global(h2) {
|
||||
font-family: var(--font-serif);
|
||||
font-size: var(--text-headline-md);
|
||||
font-weight: 400;
|
||||
letter-spacing: var(--tracking-snug);
|
||||
color: var(--on-surface);
|
||||
margin: var(--space-8) 0 var(--space-4);
|
||||
line-height: var(--leading-snug);
|
||||
}
|
||||
|
||||
.prose :global(p) {
|
||||
margin: 0 0 var(--space-4);
|
||||
color: var(--on-surface-variant);
|
||||
line-height: var(--leading-relaxed);
|
||||
font-size: var(--text-body-lg);
|
||||
}
|
||||
|
||||
.prose :global(ol),
|
||||
.prose :global(ul) {
|
||||
padding-left: var(--space-6);
|
||||
margin: 0 0 var(--space-5);
|
||||
color: var(--on-surface-variant);
|
||||
line-height: var(--leading-relaxed);
|
||||
font-size: var(--text-body-md);
|
||||
}
|
||||
|
||||
.prose :global(li) {
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.prose :global(strong) {
|
||||
font-weight: 600;
|
||||
color: var(--on-surface);
|
||||
}
|
||||
|
||||
.prose :global(em) {
|
||||
font-style: italic;
|
||||
color: var(--on-surface-muted);
|
||||
}
|
||||
|
||||
/* ── Attendance sidebar ──────────────────────────────────────────── */
|
||||
.attendance-card {
|
||||
background: var(--surface-container-lowest);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-6);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.attendance-heading {
|
||||
letter-spacing: var(--tracking-wide);
|
||||
text-transform: uppercase;
|
||||
color: var(--on-surface-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.attendance-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.attend-btn {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
background: var(--surface-container-low);
|
||||
border: var(--ghost-border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-body-sm);
|
||||
font-weight: 500;
|
||||
color: var(--on-surface-variant);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background var(--duration-fast) var(--ease-standard),
|
||||
color var(--duration-fast) var(--ease-standard);
|
||||
}
|
||||
.attend-btn:hover {
|
||||
background: var(--surface-container);
|
||||
color: var(--on-surface);
|
||||
}
|
||||
.attend-btn.active {
|
||||
background: var(--surface-container-high);
|
||||
color: var(--on-surface);
|
||||
font-weight: 600;
|
||||
}
|
||||
.attend-btn-no.active {
|
||||
background: rgba(185, 107, 88, 0.08);
|
||||
color: var(--pigment-terracotta);
|
||||
}
|
||||
|
||||
.attendance-tally {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
padding-top: var(--space-2);
|
||||
border-top: var(--ghost-border);
|
||||
}
|
||||
|
||||
.tally-yes {
|
||||
letter-spacing: var(--tracking-wide);
|
||||
text-transform: uppercase;
|
||||
color: var(--pigment-copper);
|
||||
}
|
||||
|
||||
.tally-no {
|
||||
letter-spacing: var(--tracking-wide);
|
||||
text-transform: uppercase;
|
||||
color: var(--on-surface-muted);
|
||||
}
|
||||
</style>
|
||||
Loading…
Add table
Reference in a new issue