From 16eaadce80a6f2562392a191944003e28ae17032 Mon Sep 17 00:00:00 2001 From: Patricio Date: Tue, 3 Mar 2026 23:45:09 -0300 Subject: [PATCH] Configure Notion integration for About Us page and fix Svelte CI errors --- .agent/skills/news-system/SKILL.md | 9 +-- package.json | 1 + pnpm-lock.yaml | 14 ++++ src/lib/components/LanguageSwitcher.svelte | 2 + src/lib/components/NewsCard.svelte | 1 + src/lib/components/PressLogos.svelte | 2 + src/lib/server/notion-people.ts | 77 ++++++++++++++++++++++ src/lib/server/notion.ts | 14 ++++ src/posts/australia-detail.md | 2 +- src/routes/about/+page.svelte | 11 ++++ src/routes/api/about/+server.ts | 56 +++------------- 11 files changed, 137 insertions(+), 52 deletions(-) create mode 100644 src/lib/server/notion-people.ts create mode 100644 src/lib/server/notion.ts diff --git a/.agent/skills/news-system/SKILL.md b/.agent/skills/news-system/SKILL.md index 7cc547a3b..a20b07dad 100644 --- a/.agent/skills/news-system/SKILL.md +++ b/.agent/skills/news-system/SKILL.md @@ -53,10 +53,10 @@ Simply remove `news: true` from the post's frontmatter (or set it to `false`). T **`GET /api/news`** -| Param | Default | Description | -|------------|---------|------------------------------------| -| `page` | `1` | Page number (1-indexed) | -| `pageSize` | `6` | Items per page (max 12) | +| Param | Default | Description | +| ---------- | ------- | ----------------------- | +| `page` | `1` | Page number (1-indexed) | +| `pageSize` | `6` | Items per page (max 12) | The endpoint decodes HTML entities from Substack RSS (e.g., `’` → `'`). @@ -72,6 +72,7 @@ The endpoint decodes HTML entities from Substack RSS (e.g., `’` → `'`). ## Substack Integration The Substack feed is fetched server-side from `https://pauseai.substack.com/feed`. Items are parsed from RSS XML using regex extraction for: + - `` → card title - `<description>` → card subtitle - `<link>` → card href diff --git a/package.json b/package.json index 5d6d928ee..0596ec8c7 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,7 @@ "@fontsource/roboto-slab": "^5.2.8", "@fontsource/saira-condensed": "^5.2.8", "@glidejs/glide": "~3.6.2", + "@notionhq/client": "^2.2.15", "@number-flow/svelte": "^0.3.13", "@pagefind/default-ui": "^1.4.0", "@prgm/sveltekit-progress-bar": "^2.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cf9bf9282..4ef83bad0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: '@glidejs/glide': specifier: ~3.6.2 version: 3.6.2 + '@notionhq/client': + specifier: ^2.2.15 + version: 2.2.15 '@number-flow/svelte': specifier: ^0.3.13 version: 0.3.13(svelte@4.2.20) @@ -1233,6 +1236,10 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@notionhq/client@2.2.15': + resolution: {integrity: sha512-XhdSY/4B1D34tSco/GION+23GMjaS9S2zszcqYkMHo8RcWInymF6L1x+Gk7EmHdrSxNFva2WM8orhC4BwQCwgw==} + engines: {node: '>=12'} + '@number-flow/svelte@0.3.13': resolution: {integrity: sha512-mvbxDeSFa1o/E4vGhrWuawAFCgcn5qTQ/s++FIoD88es5+JQa/aMQUypTy7qXIreTtTvncpIbkKdw9DMnweaSw==} peerDependencies: @@ -4403,6 +4410,13 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 + '@notionhq/client@2.2.15': + dependencies: + '@types/node-fetch': 2.6.13 + node-fetch: 2.7.0 + transitivePeerDependencies: + - encoding + '@number-flow/svelte@0.3.13(svelte@4.2.20)': dependencies: esm-env: 1.2.2 diff --git a/src/lib/components/LanguageSwitcher.svelte b/src/lib/components/LanguageSwitcher.svelte index a03381783..a02b1353f 100644 --- a/src/lib/components/LanguageSwitcher.svelte +++ b/src/lib/components/LanguageSwitcher.svelte @@ -165,6 +165,7 @@ <Card> <div class="list"> <!-- Add Auto-detect option at the top --> + <!-- eslint-disable-next-line svelte/no-restricted-html-elements --> <a href={deLocalizeHref($page.url.pathname)} hreflang="auto" @@ -179,6 +180,7 @@ {#each locales as locale} {@const href = localizeHref($page.url.pathname, { locale })} + <!-- eslint-disable-next-line svelte/no-restricted-html-elements --> <a {href} hreflang={locale} diff --git a/src/lib/components/NewsCard.svelte b/src/lib/components/NewsCard.svelte index b49fcb73c..9f454d4b9 100644 --- a/src/lib/components/NewsCard.svelte +++ b/src/lib/components/NewsCard.svelte @@ -112,6 +112,7 @@ opacity: 0.8; display: -webkit-box; -webkit-line-clamp: 2; + line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } diff --git a/src/lib/components/PressLogos.svelte b/src/lib/components/PressLogos.svelte index 4cf490e83..9f1a56efa 100644 --- a/src/lib/components/PressLogos.svelte +++ b/src/lib/components/PressLogos.svelte @@ -46,6 +46,7 @@ <h2 class="section-title">Media Coverage</h2> <div class="logos-row"> {#each publications as pub} + <!-- eslint-disable-next-line svelte/no-restricted-html-elements --> <a href={pub.url} target="_blank" class="pub-link"> <!-- Visible on hover (Original Color) --> <img @@ -62,6 +63,7 @@ </a> {/each} + <!-- eslint-disable-next-line svelte/no-restricted-html-elements --> <a href="/press" class="see-all"> See all coverage → </a> </div> </div> diff --git a/src/lib/server/notion-people.ts b/src/lib/server/notion-people.ts new file mode 100644 index 000000000..350f4ad7c --- /dev/null +++ b/src/lib/server/notion-people.ts @@ -0,0 +1,77 @@ +import { getNotionClient } from './notion.js' +import type { Person } from '$lib/types' +import { defaultTitle } from '$lib/config' + +export async function fetchNotionPeople(): Promise<Person[]> { + const notion = getNotionClient() + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const results: any[] = [] + let cursor: string | undefined = undefined + + const dbId = '8948430b6a9940d5976581b71d9b3cd1' + + try { + while (true) { + const response = await notion.databases.query({ + database_id: dbId!, + start_cursor: cursor + }) + + // Append correctly based on the new SDK return type + if (response.results && Array.isArray(response.results)) { + results.push(...response.results) + } + + if (!response.has_more) break + cursor = response.next_cursor || undefined + } + + return results.map(notionPageToPerson) + } catch (error) { + console.error('Error fetching from Notion:', error) + throw error + } +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function notionPageToPerson(page: any): Person { + const props = page.properties + + const getText = (propName: string) => { + const prop = props[propName] + if (!prop) return '' + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if (prop.type === 'title') return prop.title.map((t: any) => t.plain_text).join('') + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if (prop.type === 'rich_text') return prop.rich_text.map((t: any) => t.plain_text).join('') + if (prop.type === 'select') return prop.select?.name || '' + return '' + } + + const getCheckbox = (propName: string) => { + return props[propName]?.checkbox || false + } + + const getNumber = (propName: string) => { + return props[propName]?.number ?? undefined + } + + const getImage = (propName: string) => { + const files = props[propName]?.files + if (!files || files.length === 0) return undefined + const file = files[0] + return file.type === 'external' ? file.external.url : file.file.url + } + + return { + id: page.id, + name: getText('Full name'), + bio: '', + title: getText('Title') || defaultTitle, + image: getImage('Photo') || getImage('Image'), + privacy: getCheckbox('Privacy'), + checked: getCheckbox('About') || getCheckbox('ABout'), + duplicate: getCheckbox('duplicate'), + order: getNumber('About order') ?? 999 + } +} diff --git a/src/lib/server/notion.ts b/src/lib/server/notion.ts new file mode 100644 index 000000000..239322339 --- /dev/null +++ b/src/lib/server/notion.ts @@ -0,0 +1,14 @@ +import { Client } from '@notionhq/client' +import { env } from '$env/dynamic/private' + +let notion: Client | null = null + +export function getNotionClient() { + if (!notion) { + const auth = env.NOTION_API_KEY?.trim().replace(/^["']|["']$/g, '') + notion = new Client({ + auth: auth + }) + } + return notion +} diff --git a/src/posts/australia-detail.md b/src/posts/australia-detail.md index 7a74e65bb..d72ec56f3 100644 --- a/src/posts/australia-detail.md +++ b/src/posts/australia-detail.md @@ -1,5 +1,5 @@ --- -title: " PauseAI Australia (our campaigns)" +title: ' PauseAI Australia (our campaigns)' slug: australia-detail description: More information about the Australian chapter of PauseAI --- diff --git a/src/routes/about/+page.svelte b/src/routes/about/+page.svelte index e7cd99f50..9a5c059b4 100644 --- a/src/routes/about/+page.svelte +++ b/src/routes/about/+page.svelte @@ -106,6 +106,17 @@ {/each} </section> +<section class="form-section" data-pagefind-ignore> + <iframe + src="https://pauseai-global.notion.site/ebd//318811439176805e9edef71080a593cb" + width="100%" + height="600" + frameborder="0" + allowfullscreen + title="Notion Form" + ></iframe> +</section> + <section class="essential-info"> <h2>Essential Information</h2> <ul class="essential-info-list"> diff --git a/src/routes/api/about/+server.ts b/src/routes/api/about/+server.ts index cc701db03..bbb4c69c1 100644 --- a/src/routes/api/about/+server.ts +++ b/src/routes/api/about/+server.ts @@ -1,8 +1,4 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -export const prerender = false - -import { fetchAllPages } from '$lib/airtable' -import { defaultTitle } from '$lib/config' +import { fetchNotionPeople } from '$lib/server/notion-people' import type { Person } from '$lib/types' import { generateCacheControlRecord } from '$lib/utils' import { json } from '@sveltejs/kit' @@ -11,13 +7,13 @@ import { json } from '@sveltejs/kit' export type AboutApiResponse = Record<string, Person[]> /** - * Fallback people data to use in development if Airtable fetch fails + * Fallback people data to use in development if Notion fetch fails */ const fallbackPeople: Person[] = [ { id: 'fallback-stub1', name: '[FALLBACK DATA] Example Person', - bio: 'I hold places when Airtable API is unavailable.', + bio: '', title: 'Placeholder', image: 'https://api.dicebear.com/7.x/bottts/svg?seed=fallback1', privacy: false, @@ -27,7 +23,7 @@ const fallbackPeople: Person[] = [ { id: 'fallback-stub2', name: '[FALLBACK DATA] Holdor', - bio: 'Thrown at games', + bio: '', title: 'of Plays', image: 'https://api.dicebear.com/7.x/bottts/svg?seed=fallback2', privacy: false, @@ -36,24 +32,8 @@ const fallbackPeople: Person[] = [ } ] -function recordToPerson(record: any): Person { - return { - id: record.id || 'noId', - name: record.fields['Full name'], - bio: record.fields.Bio2, - title: record.fields.Title || defaultTitle, - image: (record.fields.Photo && record.fields.Photo[0].thumbnails.large.url) || undefined, - privacy: record.fields.Privacy, - checked: record.fields.About, - duplicate: record.fields.duplicate, - order: record.fields['About order'] || 999 - } -} - -const AIRTABLE_FILTER = `{Title} != ""` - const filter = (p: Person) => { - return p.checked && p.title?.trim() !== '' && p.title !== defaultTitle && !p.duplicate + return p.checked && !p.duplicate } const getGroupKey = (order: number | undefined): string => { @@ -68,31 +48,13 @@ const getGroupKey = (order: number | undefined): string => { return 'National Chapter Leads' } -export async function GET({ fetch, setHeaders }) { - const url = `https://api.airtable.com/v0/appWPTGqZmUcs3NWu/tblL1icZBhTV1gQ9o` +export async function GET({ setHeaders }) { setHeaders(generateCacheControlRecord({ public: true, maxAge: 60 * 60 })) try { - // Create fallback records in the expected Airtable format - const fallbackRecords = fallbackPeople.map((person) => ({ - id: person.id, - fields: { - 'Full name': person.name, - Bio2: person.bio, - Title: person.title, - Photo: [{ thumbnails: { large: { url: person.image } } }], - Privacy: person.privacy, - About: person.checked, // Assuming 'About' maps to checked based on your code - 'About order': person.order || 999 - } - })) - - const records = await fetchAllPages(fetch, url, fallbackRecords, { - filterByFormula: AIRTABLE_FILTER - }) - - const sortedPeople = records - .map(recordToPerson) + const people = await fetchNotionPeople() + + const sortedPeople = people .filter(filter) .sort((a, b) => { // Primary sort: numerical order field