diff --git a/.github/workflows/meeting-recordings-refresh.yml b/.github/workflows/meeting-recordings-refresh.yml new file mode 100644 index 0000000..6332058 --- /dev/null +++ b/.github/workflows/meeting-recordings-refresh.yml @@ -0,0 +1,32 @@ +name: Refresh meeting recordings + +on: + workflow_dispatch: + schedule: + - cron: '17 12 * * *' + +permissions: + contents: write + +env: + NETLIFY_BUILD_HOOK_URL: ${{ secrets.NETLIFY_BUILD_HOOK_URL }} + +jobs: + refresh: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20.11.1' + cache: npm + - run: npm ci + - run: npx tsx scripts/update-meeting-recordings.ts + - name: Commit recording feed snapshot + uses: stefanzweifel/git-auto-commit-action@v6 + with: + commit_message: 'docs: refresh community meeting recordings' + file_pattern: data/meeting-recordings.json + - name: Trigger Netlify rebuild + if: env.NETLIFY_BUILD_HOOK_URL != '' + run: curl --fail -sS -X POST "$NETLIFY_BUILD_HOOK_URL" diff --git a/data/meeting-recordings.json b/data/meeting-recordings.json new file mode 100644 index 0000000..47fad85 --- /dev/null +++ b/data/meeting-recordings.json @@ -0,0 +1,16 @@ +{ + "updatedAt": "2026-09-24T11:38:59.783Z", + "source": "https://www.youtube.com/feeds/videos.xml?channel_id=UCIA3fKJFv2nLoG6vKK65xLg", + "channelId": "UCIA3fKJFv2nLoG6vKK65xLg", + "recordings": [ + { + "id": "4pL2_thwKDA", + "title": "Hive Commons 20260910 Meeting Recording", + "publishedAt": "2026-09-10T16:07:23+00:00", + "updatedAt": "2026-09-10T16:35:39+00:00", + "url": "https://www.youtube.com/watch?v=4pL2_thwKDA", + "thumbnail": "https://i.ytimg.com/vi/4pL2_thwKDA/hqdefault.jpg", + "description": "our first meeting!" + } + ] +} diff --git a/docs/content/community/join-hive-commons.md b/docs/content/community/join-hive-commons.md index a2967d3..8c9fa42 100644 --- a/docs/content/community/join-hive-commons.md +++ b/docs/content/community/join-hive-commons.md @@ -57,4 +57,4 @@ Not ready to bring a whole project? You're still welcome: - **Join the conversation** on [hivecommons-dev@googlegroups.com](https://groups.google.com/g/hivecommons-dev), [Discord](https://hivecommons.dev/discord), or the biweekly - [community meeting](https://hivecommons.dev/agenda). + [community meeting](/docs/community/meetings). diff --git a/docs/content/community/meetings.md b/docs/content/community/meetings.md new file mode 100644 index 0000000..4e454a3 --- /dev/null +++ b/docs/content/community/meetings.md @@ -0,0 +1,3 @@ +# Community meetings + + diff --git a/docs/content/community/what-is-hive-commons.md b/docs/content/community/what-is-hive-commons.md index d1ee9e7..8a58d40 100644 --- a/docs/content/community/what-is-hive-commons.md +++ b/docs/content/community/what-is-hive-commons.md @@ -67,7 +67,7 @@ contributions. Hive Commons has applied for the - [Governance](https://github.com/hivecommons/.github/blob/main/GOVERNANCE.md) · [Maintainers](https://github.com/hivecommons/.github/blob/main/MAINTAINERS.md) · [Code of Conduct](https://github.com/hivecommons/.github/blob/main/CODE_OF_CONDUCT.md) · [Security policy](https://github.com/hivecommons/.github/blob/main/SECURITY.md) - **Mailing list:** [hivecommons-dev@googlegroups.com](https://groups.google.com/g/hivecommons-dev) — joining adds the community meeting to your calendar -- **Community meeting:** every other Thursday, 10:00 AM ET — [agenda and notes](https://hivecommons.dev/agenda) · [recordings](https://hivecommons.dev/tv) +- **Community meeting:** every other Thursday, 10:00 AM ET — [meeting page](/docs/community/meetings) · [agenda and notes](https://hivecommons.dev/agenda) · [recordings](https://hivecommons.dev/tv) - **Chat:** [Discord](https://hivecommons.dev/discord) - **Website:** [hivecommons.dev](https://hivecommons.dev) diff --git a/next.config.ts b/next.config.ts index 2ea37cf..516cd85 100644 --- a/next.config.ts +++ b/next.config.ts @@ -44,7 +44,7 @@ const nextConfig: NextConfig = { "font-src 'self' data:", "connect-src 'self' https://api.github.com https://www.google-analytics.com", "worker-src blob:", - "frame-src 'none'", + "frame-src https://www.youtube-nocookie.com", "frame-ancestors 'none'", "base-uri 'self'", "form-action 'self'", diff --git a/package.json b/package.json index 92ca48b..3fc89ab 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "private": true, "scripts": { "dev": "next dev --turbopack", - "prebuild": "(tsx scripts/sync-hive-docs.ts || echo \"hive docs sync unavailable - using committed content\") && (tsx scripts/sync-sibling-docs.ts || echo \"sibling docs sync unavailable - using committed content\") && tsx scripts/generate-shared-config.ts", + "prebuild": "(tsx scripts/update-meeting-recordings.ts || echo \"meeting recordings refresh unavailable - using committed data\") && (tsx scripts/sync-hive-docs.ts || echo \"hive docs sync unavailable - using committed content\") && (tsx scripts/sync-sibling-docs.ts || echo \"sibling docs sync unavailable - using committed content\") && tsx scripts/generate-shared-config.ts", "build": "next build", "start": "next start", "lint": "eslint src/", diff --git a/scripts/update-meeting-recordings.ts b/scripts/update-meeting-recordings.ts new file mode 100644 index 0000000..0858972 --- /dev/null +++ b/scripts/update-meeting-recordings.ts @@ -0,0 +1,109 @@ +import fs from 'fs' +import path from 'path' + +const DATA_PATH = path.join(process.cwd(), 'data', 'meeting-recordings.json') +const FEED_URL = 'https://www.youtube.com/feeds/videos.xml?channel_id=UCIA3fKJFv2nLoG6vKK65xLg' +const CHANNEL_ID = 'UCIA3fKJFv2nLoG6vKK65xLg' +const TITLE_RE = /community meeting|community call|meeting recording/i + +type Recording = { + id: string + title: string + publishedAt: string + updatedAt?: string + url: string + thumbnail: string + description?: string + duration?: string +} + +type Store = { + updatedAt: string + source: string + channelId: string + recordings: Recording[] +} + +function textBetween(input: string, tag: string): string | undefined { + const match = new RegExp(`<${tag}[^>]*>([\\s\\S]*?)`).exec(input) + return match?.[1]?.trim() +} + +function attr(input: string, tag: string, name: string): string | undefined { + const match = new RegExp(`<${tag}[^>]*\\s${name}="([^"]+)"[^>]*>`).exec(input) + return match?.[1] +} + +function decodeXml(input = ''): string { + return input + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") +} + +function parseFeed(xml: string): Recording[] { + const entries = xml.match(/[\s\S]*?<\/entry>/g) ?? [] + return entries.flatMap(entry => { + const id = textBetween(entry, 'yt:videoId') + const title = decodeXml(textBetween(entry, 'title')) + if (!id || !title || !TITLE_RE.test(title)) return [] + + return [{ + id, + title, + publishedAt: textBetween(entry, 'published') ?? '', + updatedAt: textBetween(entry, 'updated'), + url: attr(entry, 'link', 'href') ?? `https://www.youtube.com/watch?v=${id}`, + thumbnail: attr(entry, 'media:thumbnail', 'url')?.replace('https://i1.ytimg.com/', 'https://i.ytimg.com/') ?? `https://i.ytimg.com/vi/${id}/hqdefault.jpg`, + description: decodeXml(textBetween(entry, 'media:description')), + }] + }) +} + +function readExisting(): Store { + try { + return JSON.parse(fs.readFileSync(DATA_PATH, 'utf8')) as Store + } catch { + return { updatedAt: new Date(0).toISOString(), source: FEED_URL, channelId: CHANNEL_ID, recordings: [] } + } +} + +async function main() { + const existing = readExisting() + let fetched: Recording[] = [] + + try { + const response = await fetch(FEED_URL) + if (!response.ok) throw new Error(`YouTube feed returned ${response.status}`) + fetched = parseFeed(await response.text()) + } catch (error) { + console.warn(`meeting recordings feed unavailable; keeping committed data: ${error instanceof Error ? error.message : String(error)}`) + } + + const byId = new Map() + for (const recording of existing.recordings) byId.set(recording.id, recording) + for (const recording of fetched) byId.set(recording.id, { ...byId.get(recording.id), ...recording }) + + const recordings = [...byId.values()].sort((a, b) => Date.parse(b.publishedAt) - Date.parse(a.publishedAt)) + const existingStable = JSON.stringify(existing.recordings) + const nextStable = JSON.stringify(recordings) + + if (existingStable === nextStable && existing.source === FEED_URL && existing.channelId === CHANNEL_ID) { + console.log(`meeting recordings: ${existing.recordings.length} (unchanged)`) + return + } + + const next: Store = { + updatedAt: new Date().toISOString(), + source: FEED_URL, + channelId: CHANNEL_ID, + recordings, + } + + fs.writeFileSync(DATA_PATH, `${JSON.stringify(next, null, 2)}\n`) + console.log(`meeting recordings: ${next.recordings.length}`) +} + +void main() diff --git a/src/app/docs/[...slug]/page.tsx b/src/app/docs/[...slug]/page.tsx index 392cc16..1adccaf 100644 --- a/src/app/docs/[...slug]/page.tsx +++ b/src/app/docs/[...slug]/page.tsx @@ -7,6 +7,7 @@ import { sanitizeHtmlForMdx, removeCommentPatterns } from '@/lib/sanitizeHtml' import { rewriteRelativeImagePaths } from '@/lib/rewriteImagePaths' import { buildPageMap, docsContentPath, getContentPath, isGeneralSectionFile } from '../page-map' import { CURRENT_VERSION, type ProjectId } from '@/config/versions' +import { MeetingsPage } from '@/components/community/MeetingsPage' import fs from 'fs' import path from 'path' import { notFound } from 'next/navigation' @@ -207,6 +208,20 @@ export default async function DocPage({ params }: Props) { convertHtmlScriptsToJsxComments }) + if (filePath === 'community/meetings.md') { + return ( + + + + ) + } + let evaluated: ReturnType | null = null let compilationFailed = false try { diff --git a/src/app/docs/page-map.ts b/src/app/docs/page-map.ts index 2ea38ee..0aa5e66 100644 --- a/src/app/docs/page-map.ts +++ b/src/app/docs/page-map.ts @@ -90,7 +90,17 @@ const NAV_STRUCTURE_HIVE: Array<{ title: string; items: NavItem[] }> = [ title: 'Overview', items: [ { 'Introduction': 'readme.md' }, + { 'Getting Started': 'getting-started.md' }, { 'Architecture': 'architecture.md' }, + { 'Roadmap': 'roadmap.md' }, + { 'Landscape': 'landscape.md' }, + ] + }, + { + title: 'Getting started', + items: [ + { 'Manual provisioning': 'manual-provisioning.md' }, + { 'Network admin requirement': 'net-admin-requirement.md' }, ] }, { @@ -99,8 +109,47 @@ const NAV_STRUCTURE_HIVE: Array<{ title: string; items: NavItem[] }> = [ { 'Agent Definition YAML': 'agent-definition-yaml.md' }, { 'Variable Substitution': 'variable-substitution.md' }, { 'Governor': 'governor.md' }, + { 'hivectl': 'hivectl.md' }, + { 'Agent configuration': 'agent-configuration.md' }, + { 'Contributor relay': 'contributor-relay.md' }, + { 'Troubleshooting': 'troubleshooting.md' }, + { 'Release channels': 'release-channels.md' }, + { 'Backup and disaster recovery': 'backup-dr.md' }, { 'Running on macOS': 'macos.md' }, ] + }, + { + title: 'Security', + items: [ + { 'Security model': 'security-model.md' }, + { 'Security threat model': 'security-threat-model.md' }, + { 'ACMM policy matrix': 'acmm-policy-matrix.md' }, + ] + }, + { + title: 'Reference', + items: [ + { 'Architecture Decision Records': [ + { 'ADR index': 'adr/readme.md' }, + { '0001 Record architecture decisions': 'adr/0001-record-architecture-decisions.md' }, + { '0002 MITM proxy network enforcement': 'adr/0002-mitm-proxy-network-enforcement.md' }, + { '0003 ACMM autonomy levels': 'adr/0003-acmm-autonomy-levels.md' }, + { '0004 Beads work ledger': 'adr/0004-beads-work-ledger.md' }, + { '0005 Forge abstraction': 'adr/0005-forge-abstraction.md' }, + { '0006 Planning intelligence': 'adr/0006-planning-intelligence.md' }, + { '0007 Token mint': 'adr/0007-token-mint.md' }, + { '0008 ioscan untrusted input': 'adr/0008-ioscan-untrusted-input.md' }, + { '0009 Trajectory review': 'adr/0009-trajectory-review.md' }, + { '0010 Escalation circuit breaker': 'adr/0010-escalation-circuit-breaker.md' }, + { '0011 Knowledge system': 'adr/0011-knowledge-system.md' }, + { '0012 Skill registry': 'adr/0012-skill-registry.md' }, + { '0013 CEL triggers': 'adr/0013-cel-triggers.md' }, + { '0014 Hub-spoke': 'adr/0014-hub-spoke.md' }, + { '0015 CSP style-src scope': 'adr/0015-csp-style-src-scope.md' }, + { '0016 CSP script-src scope': 'adr/0016-csp-script-src-scope.md' }, + { '0017 Podman Quadlet lifecycle': 'adr/0017-podman-quadlet-lifecycle.md' }, + ] }, + ] } ] @@ -186,6 +235,7 @@ const NAV_STRUCTURE_GENERAL: Array<{ title: string; items: NavItem[] }> = [ items: [ { 'What is Hive Commons?': 'community/what-is-hive-commons.md' }, { 'Join Hive Commons': 'community/join-hive-commons.md' }, + { 'Community meetings': 'community/meetings.md' }, ] } ] @@ -262,10 +312,12 @@ export function buildPageMap(projectId: ProjectId = 'hive') { meta[title] = title } else if (allDocFiles.includes(value)) { processedFiles.add(value) - // const baseName = value.replace(/\.(md|mdx)$/i, '').split('/').pop()! - const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') // Use /docs path for general sections, project path for everything else const isGeneralSection = value.startsWith('contributing/') || value.startsWith('community/') || value.startsWith('news/') + const baseName = value.replace(/\.(md|mdx)$/i, '').split('/').pop()! + const slug = isGeneralSection + ? baseName + : title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') const basePathForRoute = isGeneralSection ? 'docs' : projectBasePath const route = `/${basePathForRoute}/${parentSlug ? parentSlug + '/' : ''}${slug}` routeMap[`${parentSlug ? parentSlug + '/' : ''}${slug}`] = value diff --git a/src/app/globals.css b/src/app/globals.css index 1dfb99d..c33670a 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -181,6 +181,213 @@ html:not(.dark) .nextra-docs-theme aside { html:not(.dark) .nextra-docs-theme .nextra-sidebar-container { background: var(--bg); } + +.hc-meetings-page { + display: grid; + gap: 1.5rem; +} + +.hc-meeting-hero, +.hc-meeting-next, +.hc-meeting-upcoming, +.hc-meeting-details, +.hc-meeting-recordings { + border: 1px solid var(--line); + border-radius: 1rem; + background: + linear-gradient(135deg, color-mix(in srgb, var(--honey) 12%, transparent), transparent 45%), + color-mix(in srgb, var(--bg-2) 88%, transparent); + padding: clamp(1rem, 3vw, 1.75rem); + box-shadow: 0 18px 60px color-mix(in srgb, #000 24%, transparent); +} + +.hc-meeting-hero h2, +.hc-meeting-next h2, +.hc-meeting-upcoming h2, +.hc-meeting-details h2, +.hc-meeting-recordings h2 { + margin-top: 0; +} + +.hc-meeting-kicker { + margin: 0 0 0.5rem; + color: var(--honey); + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.hc-meeting-actions, +.hc-meeting-sectionHead { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + align-items: center; +} + +.hc-meeting-actions a, +.hc-meeting-sectionHead a, +.hc-meeting-recordingBody a { + border: 1px solid var(--line); + border-radius: 999px; + padding: 0.6rem 0.9rem; + text-decoration: none; +} + +.hc-meeting-actions a:hover, +.hc-meeting-sectionHead a:hover, +.hc-meeting-recordingBody a:hover { + border-color: var(--honey); + color: var(--honey); +} + +.hc-meeting-actions .hc-meeting-primary { + border-color: transparent; + background: var(--honey); + color: var(--honey-ink); + font-weight: 700; +} + +.hc-meeting-actions .hc-meeting-primary:hover { + background: var(--honey-deep); + color: var(--honey-ink); +} + +.hc-meeting-grid { + display: grid; + grid-template-columns: minmax(0, 1.1fr) minmax(16rem, 0.9fr); + gap: 1rem; +} + +.hc-meeting-upcoming ol { + display: grid; + gap: 0.75rem; + margin: 0; + padding-left: 1.25rem; +} + +.hc-meeting-upcoming li span, +.hc-meeting-upcoming li small { + display: block; +} + +.hc-meeting-upcoming li small { + color: var(--ink-2); +} + +.hc-meeting-sectionHead { + justify-content: space-between; + margin-bottom: 1rem; +} + +.hc-meeting-recordingGrid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(17rem, 1fr)); + gap: 1rem; +} + +.hc-meeting-recording { + overflow: hidden; + border: 1px solid var(--line); + border-radius: 0.9rem; + background: var(--bg-3); +} + +.hc-meeting-videoShell { + position: relative; + aspect-ratio: 16 / 9; + background: #000; +} + +.hc-meeting-videoShell iframe, +.hc-meeting-videoButton, +.hc-meeting-videoButton img { + width: 100%; + height: 100%; +} + +.hc-meeting-videoShell iframe { + position: absolute; + inset: 0; + border: 0; +} + +.hc-meeting-videoButton { + position: relative; + display: block; + padding: 0; + border: 0; + cursor: pointer; + background: #000; +} + +.hc-meeting-videoButton img { + object-fit: cover; + opacity: 0.84; + transition: transform 0.2s ease, opacity 0.2s ease; +} + +.hc-meeting-videoButton:hover img, +.hc-meeting-videoButton:focus-visible img { + transform: scale(1.03); + opacity: 1; +} + +.hc-meeting-play { + position: absolute; + left: 50%; + top: 50%; + display: grid; + width: 4rem; + height: 4rem; + place-items: center; + border-radius: 999px; + background: color-mix(in srgb, var(--honey) 92%, transparent); + color: var(--honey-ink); + font-size: 1.4rem; + transform: translate(-50%, -50%); + box-shadow: 0 10px 30px color-mix(in srgb, #000 40%, transparent); +} + +.hc-meeting-recordingBody { + padding: 1rem; +} + +.hc-meeting-recordingBody h3 { + margin: 0 0 0.35rem; + font-size: 1rem; +} + +.hc-meeting-recordingBody p { + margin: 0 0 0.75rem; + color: var(--ink-2); +} + +.hc-meeting-empty { + border: 1px dashed var(--line); + border-radius: 0.75rem; + color: var(--ink-2); + padding: 1.25rem; + text-align: center; +} + +@media (max-width: 760px) { + .hc-meeting-grid { + grid-template-columns: 1fr; + } + + .hc-meeting-actions, + .hc-meeting-sectionHead { + align-items: stretch; + flex-direction: column; + } + + .hc-meeting-actions a, + .hc-meeting-sectionHead a { + text-align: center; + } +} .nextra-sidebar-container { z-index: 20; } diff --git a/src/components/community/MeetingsPage.tsx b/src/components/community/MeetingsPage.tsx new file mode 100644 index 0000000..d8f5325 --- /dev/null +++ b/src/components/community/MeetingsPage.tsx @@ -0,0 +1,248 @@ +'use client' + +import { useEffect, useState } from 'react' +import recordingsData from '../../../data/meeting-recordings.json' +import { COMMUNITY_MEETINGS } from '@/config/community-meetings' + +type Recording = { + id: string + title: string + publishedAt: string + url: string + thumbnail: string + description?: string + duration?: string +} + +const DATE_PARTS = ['year', 'month', 'day', 'hour', 'minute', 'second'] as const +const UPCOMING_COUNT = 4 + +function getTimeZoneParts(date: Date, timeZone: string) { + const formatter = new Intl.DateTimeFormat('en-US', { + timeZone, + hour12: false, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }) + + const parts = Object.fromEntries( + formatter.formatToParts(date) + .filter(part => DATE_PARTS.includes(part.type as typeof DATE_PARTS[number])) + .map(part => [part.type, Number(part.value)]) + ) as Record + + return { ...parts, hour: parts.hour === 24 ? 0 : parts.hour } +} + +function zonedTimeToDate(date: string, hour: number, minute: number, timeZone: string) { + const [year, month, day] = date.split('-').map(Number) + let utc = Date.UTC(year, month - 1, day, hour, minute) + + for (let i = 0; i < 3; i += 1) { + const parts = getTimeZoneParts(new Date(utc), timeZone) + const asUtc = Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second) + const wanted = Date.UTC(year, month - 1, day, hour, minute, 0) + utc += wanted - asUtc + } + + return new Date(utc) +} + +function addDays(date: string, days: number) { + const [year, month, day] = date.split('-').map(Number) + const next = new Date(Date.UTC(year, month - 1, day + days)) + return next.toISOString().slice(0, 10) +} + +function formatMeetingDate(date: Date, dateStyle: Intl.DateTimeFormatOptions['dateStyle'] = 'full') { + return new Intl.DateTimeFormat(undefined, { + weekday: dateStyle === 'full' ? 'long' : 'short', + year: 'numeric', + month: dateStyle === 'full' ? 'long' : 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + timeZone: COMMUNITY_MEETINGS.timeZone, + timeZoneName: 'short', + }).format(date) +} + +function formatLocalDate(date: Date) { + return new Intl.DateTimeFormat(undefined, { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + timeZoneName: 'short', + }).format(date) +} + +function getUpcomingMeetings(now = new Date()) { + const { anchorDate, cadenceDays, localTime, timeZone, exceptions } = COMMUNITY_MEETINGS + const cancelled = new Map(exceptions.map(item => [item.date, item.reason])) + const anchorStart = zonedTimeToDate(anchorDate, localTime.hour, localTime.minute, timeZone) + const daysSinceAnchor = Math.floor((now.getTime() - anchorStart.getTime()) / 86_400_000) + let index = Math.max(0, Math.floor(daysSinceAnchor / cadenceDays) - 1) + const upcoming: Array<{ date: Date; localDate: string; cancelledReason?: string }> = [] + + while (upcoming.length < UPCOMING_COUNT && index < 5000) { + const localDate = addDays(anchorDate, index * cadenceDays) + const date = zonedTimeToDate(localDate, localTime.hour, localTime.minute, timeZone) + const cancelledReason = cancelled.get(localDate) + if (date.getTime() >= now.getTime() - 60_000 && !cancelledReason) { + upcoming.push({ date, localDate }) + } + index += 1 + } + + return upcoming +} + +function calendarHref(date: Date) { + const start = date.toISOString().replace(/[-:]|\.\d{3}/g, '') + const end = new Date(date.getTime() + COMMUNITY_MEETINGS.durationMinutes * 60_000) + .toISOString() + .replace(/[-:]|\.\d{3}/g, '') + const params = new URLSearchParams({ + action: 'TEMPLATE', + text: COMMUNITY_MEETINGS.title, + dates: `${start}/${end}`, + ctz: COMMUNITY_MEETINGS.timeZone, + details: `Agenda: ${COMMUNITY_MEETINGS.agendaUrl}\nGroup: ${COMMUNITY_MEETINGS.googleGroupUrl}\nJoin: ${COMMUNITY_MEETINGS.meetingUrl}`, + }) + return `https://calendar.google.com/calendar/render?${params.toString()}` +} + +function RecordingCard({ recording, active, onPlay }: { recording: Recording; active: boolean; onPlay: () => void }) { + const title = recording.title + const published = new Date(recording.publishedAt) + + return ( +
+
+ {active ? ( +