From 44cd49dc4eef990b3e21d378c69700f8444f0caa Mon Sep 17 00:00:00 2001 From: Stephen Glass Date: Tue, 6 Oct 2026 11:32:35 -0400 Subject: [PATCH 1/3] Serve GitHub data from a build-time snapshot and add "Your Ideas" page The site is a static export, so every visitor's browser called the GitHub API unauthenticated. At the venue everyone shares one Wi-Fi IP, which gets 60 requests per hour (10 per minute for search), so the issues and Contrib Champs pages stopped working during the session. - Add scripts/fetch-github-data.mjs, which fetches curated issues and merged contribfest PRs with one batched GraphQL query and writes public/data/*.json. The browser now only loads those same-origin files. - Run the snapshot in CD on every push, every 15 minutes, and on manual dispatch. A failed fetch fails the run, so Pages keeps the last good site. - Look issues up by number instead of by the contribfest label, so the page keeps working after the post-event label cleanup. - Show an availability status per issue (available, assigned, has PR, closed), filter to available issues by default, and point people to /ideas/ when few are left. - Add the /ideas/ page to help attendees shape their own ideas into suggestion issues, BEPs, or plugin proposals, with AI prompts that coach rather than write, and a summary of the Backstage AI Use Policy. - Centralize event settings in lib/event.ts, pin the unlock time to the venue's timezone, and add an organizer runbook. - Remove unused fetch code, papaparse, and the NEXT_PUBLIC_GITHUB_TOKEN setting, which would have exposed a token in the browser bundle. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Stephen Glass --- .env.example | 8 +- .github/workflows/cd.yml | 14 ++ .gitignore | 3 + README.md | 10 +- app/contrib-champs/page.tsx | 16 +- app/getting-started/page.tsx | 6 + app/ideas/layout.tsx | 13 ++ app/ideas/page.tsx | 196 +++++++++++++++++ app/issues/IssuesPageContent.tsx | 76 +++---- app/page.tsx | 28 ++- app/sitemap.ts | 4 + components/IssueTable.tsx | 160 ++++++++++---- components/Sidebar.tsx | 1 + components/ideas/CopyButton.tsx | 50 +++++ components/ideas/IdeaCanvas.tsx | 85 +++++++ components/ideas/PathChooser.tsx | 141 ++++++++++++ docs/deployment.md | 63 +++--- docs/organizer-runbook.md | 46 ++++ hooks/useIssues.ts | 66 +++--- hooks/usePullRequests.ts | 106 +-------- lib/csv-parser.ts | 82 ------- lib/event.ts | 25 +++ lib/filters.ts | 23 +- lib/github-api.ts | 147 ------------- lib/github-pr-api.ts | 92 -------- lib/ideas.ts | 366 +++++++++++++++++++++++++++++++ lib/snapshot.ts | 20 ++ lib/types.ts | 34 ++- package.json | 5 +- scripts/fetch-github-data.mjs | 279 +++++++++++++++++++++++ yarn.lock | 34 --- 31 files changed, 1549 insertions(+), 650 deletions(-) create mode 100644 app/ideas/layout.tsx create mode 100644 app/ideas/page.tsx create mode 100644 components/ideas/CopyButton.tsx create mode 100644 components/ideas/IdeaCanvas.tsx create mode 100644 components/ideas/PathChooser.tsx create mode 100644 docs/organizer-runbook.md delete mode 100644 lib/csv-parser.ts create mode 100644 lib/event.ts delete mode 100644 lib/github-api.ts delete mode 100644 lib/github-pr-api.ts create mode 100644 lib/ideas.ts create mode 100644 lib/snapshot.ts create mode 100644 scripts/fetch-github-data.mjs diff --git a/.env.example b/.env.example index ab84d410..db1e8fb1 100644 --- a/.env.example +++ b/.env.example @@ -1,9 +1,5 @@ -# GitHub Personal Access Token -# Generate at: https://github.com/settings/tokens -# Required scope: public_repo -# With authentication: 5000 requests/hour -# Without authentication: 60 requests/hour -NEXT_PUBLIC_GITHUB_TOKEN= +# GitHub data is fetched at build time, not in the browser. Run +# `GITHUB_TOKEN=$(gh auth token) yarn fetch-data` before `yarn dev`. # Google Analytics Measurement ID # Get your GA4 Measurement ID from: https://analytics.google.com diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 5c101059..800fc38b 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -3,6 +3,11 @@ name: CD on: push: branches: [main] + # Refresh the GitHub data snapshot every 15 minutes (offset from the top of + # the hour, when scheduled runs are most often delayed). During the session, + # use "Run workflow" for an immediate refresh. + schedule: + - cron: '7,22,37,52 * * * *' workflow_dispatch: # Cancel in-progress deployments for same branch @@ -19,8 +24,10 @@ permissions: jobs: # Wait for CI to complete before deploying + # Scheduled and manual runs rebuild an already-tested commit, so only pushes wait for CI check-ci: name: Wait for CI + if: github.event_name == 'push' runs-on: ubuntu-latest steps: - name: Wait for CI workflow @@ -34,6 +41,7 @@ jobs: build: name: Build needs: check-ci + if: ${{ !cancelled() && (needs.check-ci.result == 'success' || needs.check-ci.result == 'skipped') }} runs-on: ubuntu-latest timeout-minutes: 15 @@ -67,6 +75,12 @@ jobs: - name: Install dependencies run: yarn install --immutable + # Fails the run on any GitHub error, so Pages keeps serving the last good deploy + - name: Fetch GitHub data snapshot + run: yarn fetch-data + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Build run: yarn build diff --git a/.gitignore b/.gitignore index b8006cb0..2ebe51af 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,9 @@ # production /build +# GitHub data snapshot (generated by `yarn fetch-data`) +/public/data/ + # misc .DS_Store *.pem diff --git a/README.md b/README.md index 7b635c0b..12cbaba7 100644 --- a/README.md +++ b/README.md @@ -26,13 +26,19 @@ cp .env.example .env.local yarn install ``` -4. Run the development server: +4. Fetch the GitHub data snapshot used by the issues and Contrib Champs pages: + +```bash +GITHUB_TOKEN=$(gh auth token) yarn fetch-data +``` + +5. Run the development server: ```bash yarn dev ``` -Open [http://localhost:3000](http://localhost:3000) to view the application. +Open [http://localhost:3000](http://localhost:3000) to view the application. See [docs/deployment.md](docs/deployment.md) for how the snapshot is refreshed in production, and [docs/organizer-runbook.md](docs/organizer-runbook.md) for running an event. ## Build diff --git a/app/contrib-champs/page.tsx b/app/contrib-champs/page.tsx index eb7bd254..4ad47449 100644 --- a/app/contrib-champs/page.tsx +++ b/app/contrib-champs/page.tsx @@ -4,9 +4,10 @@ import { usePullRequests } from '@/hooks/usePullRequests' import { PullRequestTable } from '@/components/PullRequestTable' import { SessionsSidebar } from '@/components/SessionsSidebar' import { sessions } from '@/lib/sessions' +import { formatUpdatedAgo } from '@/lib/snapshot' export default function ContribChampsPage() { - const { pullRequests, loading, error } = usePullRequests() + const { pullRequests, generatedAt, loading, error } = usePullRequests() return (
@@ -34,6 +35,19 @@ export default function ContribChampsPage() { Backstage and Community Plugins repositories โ€” huge thanks to everyone who made them happen!

+ {generatedAt && ( +

+ GitHub data updated {formatUpdatedAgo(generatedAt)} +

+ )}
{/* Loading State */} diff --git a/app/getting-started/page.tsx b/app/getting-started/page.tsx index 0a38b9bb..204e8ddf 100644 --- a/app/getting-started/page.tsx +++ b/app/getting-started/page.tsx @@ -160,6 +160,12 @@ const sidebarResources: ResourceCard[] = [ description: 'Details about the Backend System used by Backstage.', url: 'https://backstage.io/docs/backend-system/', isExternal: true + }, + { + title: 'Using AI Tools', + description: 'AI assistants are welcome, but you must understand and test every change, and mark largely AI-generated PRs as such.', + url: 'https://github.com/backstage/backstage/blob/master/CONTRIBUTING.md#ai-use-policy-and-guidelines', + isExternal: true } ]; diff --git a/app/ideas/layout.tsx b/app/ideas/layout.tsx new file mode 100644 index 00000000..eb9db9f8 --- /dev/null +++ b/app/ideas/layout.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next' + +export const metadata: Metadata = { + title: 'Your Ideas', +} + +export default function IdeasLayout({ + children, +}: { + children: React.ReactNode +}) { + return <>{children} +} diff --git a/app/ideas/page.tsx b/app/ideas/page.tsx new file mode 100644 index 00000000..ae74bc12 --- /dev/null +++ b/app/ideas/page.tsx @@ -0,0 +1,196 @@ +import { RiExternalLinkLine } from '@remixicon/react' +import { PathChooser } from '@/components/ideas/PathChooser' +import { IdeaCanvas } from '@/components/ideas/IdeaCanvas' +import { CopyButton } from '@/components/ideas/CopyButton' +import { aiPolicyPoints, aiPolicyUrl, coachPrompts, nextSteps, topicAreas } from '@/lib/ideas' + +export default function IdeasPage() { + return ( +
+
+

+ ๐Ÿ’ก Your Ideas +

+

+ ContribFest isn't only about closing issues. Your experience running and building on Backstage is + the best source of ideas for where it should go next. This page helps you shape an idea and find the + right way to propose it. The writing is yours, and the hosts are here to help. +

+
+ +
+ +
+ +
+
+ {topicAreas.map((topic) => ( +
+

+ {topic.title} +

+

+ {topic.summary} +

+
    + {topic.questions.map((question) => ( +
  • {question}
  • + ))} +
+
+ Docs + Code + Top requests +
+
+ ))} +
+
+ +
+ +
+ +
+
+

+ Backstage AI Use Policy +

+
    + {aiPolicyPoints.map((point) => ( +
  • {point}
  • + ))} +
+ Read the full policy +
+ +
+ {coachPrompts.map((coachPrompt) => ( +
+

+ {coachPrompt.title} +

+

+ {coachPrompt.purpose} +

+
+ {coachPrompt.prompt} +
+
+ +
+
+ ))} +
+
+ +
+
    + {nextSteps.map((step, index) => ( +
  1. + +
    +

    + {step.title} +

    +

    + {step.description} +

    + {step.link && ( +
    + {step.link.label} +
    + )} +
    +
  2. + ))} +
+
+
+ ) +} + +const cardStyle: React.CSSProperties = { + border: '1px solid var(--bui-border-1, #d5d5d5)', + borderRadius: '8px', + padding: '20px', + background: 'var(--bui-bg-popover, #fff)', + display: 'flex', + flexDirection: 'column', +} + +function Section({ title, intro, children }: { title: string; intro: string; children: React.ReactNode }) { + return ( +
+

+ {title} +

+

+ {intro} +

+ {children} +
+ ) +} + +function ExternalLink({ href, children }: { href: string; children: React.ReactNode }) { + return ( + + {children} + + ) +} diff --git a/app/issues/IssuesPageContent.tsx b/app/issues/IssuesPageContent.tsx index c45bc2d0..adc7a61c 100644 --- a/app/issues/IssuesPageContent.tsx +++ b/app/issues/IssuesPageContent.tsx @@ -6,9 +6,9 @@ import { CountdownModal } from '@/components/CountdownModal' import { InactiveIssuesPage } from '@/components/InactiveIssuesPage' import { useSearchParams } from 'next/navigation' import { useMemo } from 'react' - -// Set to true when ContribFest is active -const CONTRIBFEST_ACTIVE = true +import Link from 'next/link' +import { CONTRIBFEST_ACTIVE, EVENT_TIMEZONE, ISSUES_UNLOCK_AT } from '@/lib/event' +import { formatUpdatedAgo } from '@/lib/snapshot' export default function IssuesPageContent() { const searchParams = useSearchParams() @@ -17,19 +17,10 @@ export default function IssuesPageContent() { const showCuratedList = CONTRIBFEST_ACTIVE || isAdmin - // Check if access is allowed (after November 11, 2026 or admin bypass) - const { accessAllowed, targetDate } = useMemo(() => { - const target = new Date('2026-11-11T00:00:00') - const now = new Date() - const allowed = now >= target || isAdmin - - return { - accessAllowed: allowed, - targetDate: target, - } - }, [isAdmin]) + // Check if access is allowed (after the unlock date or admin bypass) + const accessAllowed = useMemo(() => new Date() >= ISSUES_UNLOCK_AT || isAdmin, [isAdmin]) - const { issues, loading, error, progress } = useIssues(showCuratedList && accessAllowed) + const { issues, generatedAt, loading, error } = useIssues(showCuratedList && accessAllowed) if (!showCuratedList) { return @@ -58,7 +49,25 @@ export default function IssuesPageContent() { > Browse {issues.length > 0 && `${issues.length} `}hand-picked GitHub issues from Backstage and Community Plugins repositories. Use filters to find issues that match your interests and skill level. + Have something else in mind?{' '} + + Bring your own idea + + .

+ {generatedAt && ( +

+ GitHub data updated {formatUpdatedAgo(generatedAt)} +

+ )} {/* Loading State */} @@ -81,37 +90,6 @@ export default function IssuesPageContent() { > Loading issues... - {progress && ( -
-
- Fetching metadata: {progress.current} / {progress.total} -
-
-
-
-
- )}
)} @@ -149,12 +127,14 @@ export default function IssuesPageContent() { color: 'var(--bui-fg-secondary, #666)', }} > - No issues found. + {accessAllowed + ? 'No issues found.' + : `The curated issues list unlocks on ${ISSUES_UNLOCK_AT.toLocaleDateString(undefined, { month: 'long', day: 'numeric', year: 'numeric', timeZone: EVENT_TIMEZONE })}.`} )} {/* Countdown Modal - shown if access is not allowed */} - {!accessAllowed && } + {!accessAllowed && } ) } diff --git a/app/page.tsx b/app/page.tsx index 3fed229b..6386f590 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,26 +1,31 @@ import { WelcomeCard } from '@/components/WelcomeCard' import type { ResourceCard } from '@/lib/types' - -// Set to true when ContribFest is active -const CONTRIBFEST_ACTIVE = true +import { CONTRIBFEST_ACTIVE, currentSession, offSeasonMessage } from '@/lib/event' const sessionResources: ResourceCard[] = [ { title: 'ContribFest Slide Deck', description: 'Presentation slides with workshop overview, contribution tips, and resources. A handy reference for workshop structure and guidance throughout the session.', - url: `/KubeCon-SLC-Backstage-ContribFest-2026.pdf`, + url: currentSession.slideDeckPath, isExternal: true, note: 'The slide deck will be available before the event.', }, { title: 'Assignment Sheet', description: - 'Google Sheet for participants to assign themselves to issues they want to work on. Claim your issue and track progress during the session.', - url: 'https://docs.google.com/spreadsheets/d/1Bw3fclWcIt7_x7NlpWnJ0DTiaEQqjQ2KkvySiofoHZQ/edit?gid=232343915#gid=232343915', + 'Google Sheet for participants to assign themselves to issues they want to work on. Check the availability badge on Curated Issues first, then claim your issue and track progress during the session.', + url: currentSession.assignmentSheetUrl, isExternal: true, note: 'Access to the Assignment Sheet will be provided on the day of the event.', }, + { + title: 'Bring Your Own Idea', + description: + 'Have an idea for Backstage? Explore topic areas, pick the right path (feature request, BEP, or plugin proposal), and shape it with help from the hosts.', + url: '/ideas/', + isExternal: false, + }, ] const repositoryResources: ResourceCard[] = [ @@ -106,8 +111,7 @@ export default function Page() { lineHeight: '1.6', }} > - Backstage ContribFest will return at KubeCon Barcelona in March 2027, with more - details to be shared nearer the date. + {offSeasonMessage} )} @@ -123,10 +127,10 @@ export default function Page() { lineHeight: '1.6', }} > - The next Backstage ContribFest session takes place at KubeCon in Salt Lake City on - November 11, 2026 at 4:10 PM MST in Room 255 D (Salt Palace, Level 2), be sure to{' '} + The next Backstage ContribFest session takes place at KubeCon in {currentSession.city} on{' '} + {currentSession.details}, be sure to{' '} diff --git a/app/sitemap.ts b/app/sitemap.ts index ebc34223..8f8d8ddc 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -22,6 +22,10 @@ export default function sitemap(): MetadataRoute.Sitemap { url: `${BASE_URL}/hall-of-hosts/`, lastModified: new Date(), }, + { + url: `${BASE_URL}/ideas/`, + lastModified: new Date(), + }, { url: `${BASE_URL}/issues/`, lastModified: new Date(), diff --git a/components/IssueTable.tsx b/components/IssueTable.tsx index 6818e7a1..c27a0fe6 100644 --- a/components/IssueTable.tsx +++ b/components/IssueTable.tsx @@ -1,9 +1,12 @@ 'use client' import { useState, useMemo } from 'react' -import type { EnrichedIssue } from '@/lib/types' +import Link from 'next/link' +import type { EnrichedIssue, IssueAvailability } from '@/lib/types' import { + availabilityOptions, filterIssues, + getAvailabilitySortOrder, getUniqueRepositories, getUniqueLevels, getUniqueLabels, @@ -17,14 +20,14 @@ interface IssueTableProps { initialRepository?: string } -type SortColumn = 'rowNumber' | 'repository' | 'level' | 'issueId' | 'title' | 'state' +type SortColumn = 'rowNumber' | 'repository' | 'level' | 'issueId' | 'title' | 'availability' type SortDirection = 'asc' | 'desc' export function IssueTable({ issues, initialRepository }: IssueTableProps) { const [filters, setFilters] = useState({ search: '', repository: initialRepository || 'all', - state: 'all', + availability: 'available', level: 'all', label: 'all', }) @@ -42,6 +45,15 @@ export function IssueTable({ issues, initialRepository }: IssueTableProps) { [issues, filters] ) + // How many issues are still up for grabs given the other filters + const availableCount = useMemo( + () => + filterIssues(issues, { ...filters, availability: 'all' }).filter( + (issue) => issue.availability === 'available' + ).length, + [issues, filters] + ) + // Handle column sort const handleSort = (column: SortColumn) => { if (sortColumn === column) { @@ -83,22 +95,26 @@ export function IssueTable({ issues, initialRepository }: IssueTableProps) { aValue = a.githubData?.title || '' bValue = b.githubData?.title || '' break - case 'state': - aValue = a.githubData?.state || '' - bValue = b.githubData?.state || '' + case 'availability': + aValue = getAvailabilitySortOrder(a.availability) + bValue = getAvailabilitySortOrder(b.availability) break } if (typeof aValue === 'number' && typeof bValue === 'number') { - return sortDirection === 'asc' ? aValue - bValue : bValue - aValue + if (aValue !== bValue) { + return sortDirection === 'asc' ? aValue - bValue : bValue - aValue + } + } else { + // String comparison + const aStr = String(aValue).toLowerCase() + const bStr = String(bValue).toLowerCase() + if (aStr < bStr) return sortDirection === 'asc' ? -1 : 1 + if (aStr > bStr) return sortDirection === 'asc' ? 1 : -1 } - // String comparison - const aStr = String(aValue).toLowerCase() - const bStr = String(bValue).toLowerCase() - if (aStr < bStr) return sortDirection === 'asc' ? -1 : 1 - if (aStr > bStr) return sortDirection === 'asc' ? 1 : -1 - return 0 + // Tie-break so available issues are listed first + return getAvailabilitySortOrder(a.availability) - getAvailabilitySortOrder(b.availability) }) return sorted }, [filteredIssues, sortColumn, sortDirection]) @@ -321,7 +337,7 @@ export function IssueTable({ issues, initialRepository }: IssueTableProps) {
@@ -382,9 +401,31 @@ export function IssueTable({ issues, initialRepository }: IssueTableProps) { color: 'var(--bui-fg-secondary, #666)', }} > - Showing {sortedIssues.length} of {issues.length} issues + Showing {sortedIssues.length} of {issues.length} issues ยท {availableCount} available + {/* Fallback when the small issues have all been picked up */} + {availableCount <= 3 && ( +
+ {availableCount === 0 ? 'All of these issues have been picked up' : 'Only a few of these issues are still available'} + {' '}โ€” nice work, everyone! Small issues aren't the only way to contribute:{' '} + + bring your own idea โ†’ + +
+ )} + {/* Table */}
handleSort('state')} + onClick={() => handleSort('availability')} > - State {sortColumn === 'state' && (sortDirection === 'asc' ? 'โ†‘' : 'โ†“')} + Availability {sortColumn === 'availability' && (sortDirection === 'asc' ? 'โ†‘' : 'โ†“')} @@ -490,24 +531,7 @@ export function IssueTable({ issues, initialRepository }: IssueTableProps) { )}
Labels
- {issue.githubData?.state && ( - - {issue.githubData.state} - - )} +
@@ -558,6 +582,60 @@ export function IssueTable({ issues, initialRepository }: IssueTableProps) { ) } +function AvailabilityBadge({ issue }: { issue: EnrichedIssue }) { + if (!issue.availability) return null + + const style = { + padding: '4px 8px', + borderRadius: '4px', + fontSize: '12px', + fontWeight: 500, + whiteSpace: 'nowrap' as const, + ...getAvailabilityBadgeColor(issue.availability), + } + const label = availabilityOptions.find((option) => option.value === issue.availability)?.label + + // Link straight to the PR so people can see who is already on it + const pr = issue.linkedPRs?.[0] + if (issue.availability === 'has-pr' && pr) { + return ( + + {label} #{pr.number} + + ) + } + + const title = + issue.availability === 'assigned' && issue.assignees?.length + ? `Assigned to ${issue.assignees.map((login) => `@${login}`).join(', ')}` + : undefined + + return ( + + {label} + + ) +} + +function getAvailabilityBadgeColor(availability: IssueAvailability): { background: string; color: string } { + switch (availability) { + case 'available': + return { background: '#d4edda', color: '#155724' } + case 'assigned': + return { background: '#fef3c7', color: '#92400e' } + case 'has-pr': + return { background: '#dbeafe', color: '#1e40af' } + case 'closed': + return { background: '#e5e7eb', color: '#374151' } + } +} + const thStyle: React.CSSProperties = { padding: '12px', textAlign: 'left', diff --git a/components/Sidebar.tsx b/components/Sidebar.tsx index ec6f3fc0..6af70164 100644 --- a/components/Sidebar.tsx +++ b/components/Sidebar.tsx @@ -10,6 +10,7 @@ const navigationLinks = [ { href: '/', label: 'Welcome', emoji: '๐Ÿ‘‹', image: 'waving_drk.png' }, { href: '/getting-started/', label: 'Getting Started', emoji: '๐Ÿš€', image: 'walking_drk.png' }, { href: '/issues/', label: 'Curated Issues', emoji: '๐Ÿ”', image: 'looking_drk.png' }, + { href: '/ideas/', label: 'Your Ideas', emoji: '๐Ÿ’ก', image: 'building_drk.png' }, { href: '/contrib-champs/', label: 'Contrib Champs', emoji: '๐Ÿ†', image: 'love_backstage_drk.png' }, { href: '/hall-of-hosts/', label: 'Hall of Hosts', emoji: '๐Ÿ™', image: 'community_organizer_drk.png' }, ] diff --git a/components/ideas/CopyButton.tsx b/components/ideas/CopyButton.tsx new file mode 100644 index 00000000..95bf8d86 --- /dev/null +++ b/components/ideas/CopyButton.tsx @@ -0,0 +1,50 @@ +'use client' + +import { useState } from 'react' +import { RiCheckLine, RiFileCopyLine } from '@remixicon/react' + +interface CopyButtonProps { + text: string + label?: string + disabled?: boolean +} + +export function CopyButton({ text, label = 'Copy', disabled = false }: CopyButtonProps) { + const [copied, setCopied] = useState(false) + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(text) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } catch (error) { + console.error('Failed to copy to clipboard:', error) + } + } + + return ( + + ) +} diff --git a/components/ideas/IdeaCanvas.tsx b/components/ideas/IdeaCanvas.tsx new file mode 100644 index 00000000..0e91f496 --- /dev/null +++ b/components/ideas/IdeaCanvas.tsx @@ -0,0 +1,85 @@ +'use client' + +import { useLocalStorage } from '@/hooks/useLocalStorage' +import { canvasQuestions } from '@/lib/ideas' +import { CopyButton } from './CopyButton' + +// Formats only what the attendee typed, under the canvas headings +function formatNotes(answers: Record): string { + return canvasQuestions + .filter((question) => answers[question.id]?.trim()) + .map((question) => `## ${question.heading}\n\n${answers[question.id].trim()}`) + .join('\n\n') +} + +export function IdeaCanvas() { + const [answers, setAnswers] = useLocalStorage>('contribfest-idea-canvas', {}) + const notes = formatNotes(answers) + + const handleReset = () => { + if (window.confirm('Clear all of your idea notes? This cannot be undone.')) { + setAnswers({}) + } + } + + return ( +
+
+ {canvasQuestions.map((question) => ( +
+ +

+ {question.prompt} +

+