├── .nvmrc
├── .vscode
└── settings.json
├── public
├── favicon
│ ├── favicon.ico
│ ├── favicon-16x16.png
│ ├── favicon-32x32.png
│ ├── apple-touch-icon.png
│ ├── android-chrome-192x192.png
│ └── android-chrome-512x512.png
├── images
│ ├── spaceinvader.png
│ ├── game-workers-share-card.png
│ ├── game-workers-share-card-new.png
│ └── GameWorkerSolidarity_Logo_Transparent.png
├── fonts
│ ├── Parabole-Regular.ttf
│ ├── Parabole-Regular.woff
│ └── Parabole-Regular.woff2
├── site.webmanifest
└── vercel.svg
├── docs
└── airtable_access_token_config.png
├── postcss.config.js
├── utils
├── swr.ts
├── environment.ts
├── airtable.ts
├── cors.ts
├── router.ts
├── geo.ts
├── string.ts
├── mediaQuery.ts
├── screens.ts
└── state.ts
├── .babelrc.js
├── next-env.d.ts
├── next.config.js
├── data
├── seo.ts
├── airtableValidation.ts
├── markdown.ts
├── airtable.ts
├── cloudinary.ts
├── geo.ts
├── site.ts
├── staticPage.ts
├── blogPost.ts
├── company.ts
├── category.ts
├── organisingGroup.ts
├── country.ts
├── solidarityAction.ts
├── cdn.ts
├── types.ts
└── schema.ts
├── jest.setup.js
├── components
├── Date.tsx
├── LoadingPage.tsx
├── BlogPost.tsx
├── KonamiCode.tsx
├── Filter.tsx
├── ActionChart.tsx
├── PageLayout.tsx
├── GameLogo.tsx
├── OrganisingGroup.tsx
├── Map.tsx
└── SolidarityActions.tsx
├── next-sitemap.js
├── pages
├── rss.xml.tsx
├── api
│ ├── solidarityActions.ts
│ ├── countryData.ts
│ ├── country.ts
│ ├── organisingGroupsByCountry.ts
│ ├── syncToCDN.ts
│ ├── revalidate.ts
│ ├── validateAirtableData.ts
│ └── createOrRefreshAirtableWebhook.ts
├── 404.tsx
├── 500.tsx
├── server-sitemap.xml
│ └── index.tsx
├── feed.json.tsx
├── analysis.tsx
├── submit.tsx
├── data.tsx
├── index.tsx
├── _app.tsx
├── [...slug].tsx
├── group
│ └── [groupId].tsx
├── action
│ └── [actionId].tsx
├── about.tsx
└── analysis
│ └── [slug].tsx
├── .gitignore
├── .github
├── workflows
│ ├── refreshWebhook.yml
│ └── validateAirtableData.yml
└── pull_request_template
├── jest.config.js
├── tsconfig.json
├── .env.template
├── __tests__
└── airtable.tsx
├── styles
├── globals.css
└── Home.module.css
├── package.json
├── tailwind.config.js
└── README.md
/.nvmrc:
--------------------------------------------------------------------------------
1 | v15.8.0
2 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "typescript.tsdk": "node_modules/typescript/lib"
3 | }
--------------------------------------------------------------------------------
/public/favicon/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gameworkersolidarity/website/HEAD/public/favicon/favicon.ico
--------------------------------------------------------------------------------
/public/favicon/favicon-16x16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gameworkersolidarity/website/HEAD/public/favicon/favicon-16x16.png
--------------------------------------------------------------------------------
/public/favicon/favicon-32x32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gameworkersolidarity/website/HEAD/public/favicon/favicon-32x32.png
--------------------------------------------------------------------------------
/public/images/spaceinvader.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gameworkersolidarity/website/HEAD/public/images/spaceinvader.png
--------------------------------------------------------------------------------
/public/fonts/Parabole-Regular.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gameworkersolidarity/website/HEAD/public/fonts/Parabole-Regular.ttf
--------------------------------------------------------------------------------
/public/fonts/Parabole-Regular.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gameworkersolidarity/website/HEAD/public/fonts/Parabole-Regular.woff
--------------------------------------------------------------------------------
/docs/airtable_access_token_config.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gameworkersolidarity/website/HEAD/docs/airtable_access_token_config.png
--------------------------------------------------------------------------------
/public/favicon/apple-touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gameworkersolidarity/website/HEAD/public/favicon/apple-touch-icon.png
--------------------------------------------------------------------------------
/public/fonts/Parabole-Regular.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gameworkersolidarity/website/HEAD/public/fonts/Parabole-Regular.woff2
--------------------------------------------------------------------------------
/public/favicon/android-chrome-192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gameworkersolidarity/website/HEAD/public/favicon/android-chrome-192x192.png
--------------------------------------------------------------------------------
/public/favicon/android-chrome-512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gameworkersolidarity/website/HEAD/public/favicon/android-chrome-512x512.png
--------------------------------------------------------------------------------
/public/images/game-workers-share-card.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gameworkersolidarity/website/HEAD/public/images/game-workers-share-card.png
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | 'postcss-nested': {},
6 | },
7 | }
8 |
--------------------------------------------------------------------------------
/public/images/game-workers-share-card-new.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gameworkersolidarity/website/HEAD/public/images/game-workers-share-card-new.png
--------------------------------------------------------------------------------
/public/images/GameWorkerSolidarity_Logo_Transparent.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gameworkersolidarity/website/HEAD/public/images/GameWorkerSolidarity_Logo_Transparent.png
--------------------------------------------------------------------------------
/utils/swr.ts:
--------------------------------------------------------------------------------
1 | export const doNotFetch = () => {
2 | return {
3 | revalidateOnMount: false,
4 | revalidateOnFocus: false,
5 | revalidateOnReconnect: false
6 | }
7 | }
--------------------------------------------------------------------------------
/.babelrc.js:
--------------------------------------------------------------------------------
1 | // In .babelrc.js
2 | module.exports = {
3 | presets: [['next/babel', { 'preset-react': { runtime: 'automatic' } }]],
4 | plugins: ['babel-plugin-macros', 'polished'],
5 | }
--------------------------------------------------------------------------------
/next-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 |
4 | // NOTE: This file should not be edited
5 | // see https://nextjs.org/docs/basic-features/typescript for more information.
6 |
--------------------------------------------------------------------------------
/next.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | webpack: (config, { isServer }) => {
3 | if (!isServer) {
4 | config.resolve.fallback.fs = false;
5 | }
6 | return config;
7 | },
8 | images: {
9 | domains: ['res.cloudinary.com'],
10 | },
11 | }
--------------------------------------------------------------------------------
/public/site.webmanifest:
--------------------------------------------------------------------------------
1 | {"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
--------------------------------------------------------------------------------
/data/seo.ts:
--------------------------------------------------------------------------------
1 | import { useRouter } from 'next/dist/client/router';
2 | import { projectStrings } from './site';
3 |
4 | export const useCanonicalURL = (path?: string) => {
5 | const router = useRouter()
6 | return (new URL(path || router.asPath, projectStrings.baseUrl)).toString()
7 | }
--------------------------------------------------------------------------------
/jest.setup.js:
--------------------------------------------------------------------------------
1 | // Optional: configure or set up a testing framework before each test.
2 | // If you delete this file, remove `setupFilesAfterEnv` from `jest.config.js`
3 |
4 | // Used for __tests__/testing-library.js
5 | // Learn more: https://github.com/testing-library/jest-dom
6 | import '@testing-library/jest-dom/extend-expect'
--------------------------------------------------------------------------------
/utils/environment.ts:
--------------------------------------------------------------------------------
1 | export const isClient = typeof window !== "undefined";
2 | export const isServer = typeof window === "undefined";
3 |
4 | export const isAnalyzing = process.env.ANALYZE === "true";
5 | export const isDev = process.env.NODE_ENV === "development";
6 | export const isProd = process.env.NODE_ENV === "production";
--------------------------------------------------------------------------------
/data/airtableValidation.ts:
--------------------------------------------------------------------------------
1 | import { SolidarityActionAirtableRecord } from './types';
2 |
3 | const lowercaseAlphanumericSlugRegex = /^[a-z0-9]+(?:[-_][a-z0-9]+)*$/
4 |
5 | export function validateAirtableAction (action: SolidarityActionAirtableRecord): boolean {
6 | return !!action.fields.slug?.match(lowercaseAlphanumericSlugRegex)
7 | }
--------------------------------------------------------------------------------
/data/markdown.ts:
--------------------------------------------------------------------------------
1 | import MarkdownIt from 'markdown-it'
2 | import removeMd from 'remove-markdown'
3 | const markdown = new MarkdownIt();
4 |
5 | export function parseMarkdown(md: string) {
6 | const html = markdown.render(md)
7 | return {
8 | html: html as string,
9 | plaintext: removeMd(html) as string
10 | }
11 | }
--------------------------------------------------------------------------------
/data/airtable.ts:
--------------------------------------------------------------------------------
1 | import Airtable from 'airtable'
2 | import env from 'env-var'
3 |
4 | export const airtableBase = () => new Airtable({
5 | apiKey: env.get('AIRTABLE_API_KEY')
6 | .required()
7 | .asString()
8 | }).base(
9 | env.get('AIRTABLE_BASE_ID')
10 | .default('appeAmlnDhmq6QSDi')
11 | .required()
12 | .asString()
13 | );
--------------------------------------------------------------------------------
/components/Date.tsx:
--------------------------------------------------------------------------------
1 | import { format as formatDate } from 'date-fns';
2 |
3 | export function DateTime ({ date, format = 'dd MMM yyyy' }: { date: string | Date, format?: string }) {
4 | const _date = new Date(date)
5 | return (
6 |
7 | {formatDate(_date, format)}
8 |
9 | )
10 | }
--------------------------------------------------------------------------------
/next-sitemap.js:
--------------------------------------------------------------------------------
1 | const env = require('env-var')
2 | const siteUrl = env.get('SITE_BASE_URL').default('https://gameworkersolidarity.com').asString()
3 |
4 | module.exports = {
5 | siteUrl,
6 | generateRobotsTxt: true,
7 | exclude: [
8 | '/api/*',
9 | '/action/*',
10 | '/group/*',
11 | '/server-sitemap.xml'
12 | ],
13 | robotsTxtOptions: {
14 | additionalSitemaps: [
15 | `${siteUrl}/server-sitemap.xml`,
16 | ],
17 | },
18 | }
--------------------------------------------------------------------------------
/utils/airtable.ts:
--------------------------------------------------------------------------------
1 | export function airtableFilterOperation (operation: string, ...args: (string | undefined | null)[]): string {
2 | return `${operation}(${args.filter(Boolean).join(', ')})`
3 | }
4 |
5 | export function airtableFilterOR (...args: (string | undefined | null)[]): string {
6 | return airtableFilterOperation('OR', ...args)
7 | }
8 |
9 | export function airtableFilterAND (...args: (string | undefined | null)[]): string {
10 | return airtableFilterOperation('AND', ...args)
11 | }
--------------------------------------------------------------------------------
/pages/rss.xml.tsx:
--------------------------------------------------------------------------------
1 | import jsonfeedToRSS from 'jsonfeed-to-rss'
2 | import { generateJSONFeed } from './feed.json';
3 |
4 | export default function Page () {
5 | return null
6 | }
7 |
8 | export async function getServerSideProps(context) {
9 | const res = context.res;
10 | if (!res) {
11 | return;
12 | }
13 | // fetch your RSS data from somewhere here
14 | const JSONFeed = await generateJSONFeed()
15 | const blogPosts = jsonfeedToRSS(JSONFeed);
16 | res.setHeader("Content-Type", "text/xml");
17 | res.write(blogPosts);
18 | res.end();
19 | }
--------------------------------------------------------------------------------
/utils/cors.ts:
--------------------------------------------------------------------------------
1 | import Cors from 'cors'
2 |
3 | // Initializing the cors middleware
4 | export const corsGET = Cors({
5 | methods: ['GET', 'HEAD'],
6 | })
7 |
8 | // Helper method to wait for a middleware to execute before continuing
9 | // And to throw an error when an error happens in a middleware
10 | export function runMiddleware(req, res, fn) {
11 | return new Promise((resolve, reject) => {
12 | fn(req, res, (result) => {
13 | if (result instanceof Error) {
14 | return reject(result)
15 | }
16 |
17 | return resolve(result)
18 | })
19 | })
20 | }
--------------------------------------------------------------------------------
/utils/router.ts:
--------------------------------------------------------------------------------
1 | import { NextRouter } from "next/dist/client/router"
2 |
3 | import scrollIntoView from 'scroll-into-view'
4 |
5 | export function scrollToYear(router: NextRouter, year: string) {
6 | const element = document.getElementById(year)
7 | const header = document.getElementById('sticky-header')
8 |
9 | if (!element) return
10 | if (!header) return
11 |
12 | const headerHeight = header.offsetHeight
13 | const headerScrollPadding = 8
14 |
15 | scrollIntoView(element, {
16 | align:{
17 | top: 0,
18 | topOffset: headerHeight + headerScrollPadding
19 | }
20 | })
21 | }
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # next.js
12 | /.next/
13 | /out/
14 |
15 | # production
16 | /build
17 |
18 | # misc
19 | .DS_Store
20 | *.pem
21 |
22 | # debug
23 | npm-debug.log*
24 | yarn-debug.log*
25 | yarn-error.log*
26 |
27 | # local env files
28 | .env.local
29 | .env.development.local
30 | .env.test.local
31 | .env.production.local
32 |
33 | # vercel
34 | .vercel
35 |
36 | public/robots.txt
37 |
38 | public/sitemap.xml
39 |
40 | .env
41 |
--------------------------------------------------------------------------------
/components/LoadingPage.tsx:
--------------------------------------------------------------------------------
1 | export default function LoadingPage () {
2 | return (
3 |
9 | )
10 | }
--------------------------------------------------------------------------------
/pages/api/solidarityActions.ts:
--------------------------------------------------------------------------------
1 | import type { NextApiRequest, NextApiResponse } from 'next'
2 | import { SolidarityAction } from '../../data/types';
3 | import { getLiveSolidarityActions } from '../../data/solidarityAction';
4 | import { corsGET, runMiddleware } from '../../utils/cors';
5 |
6 | export type SolidarityActionsData = {
7 | solidarityActions: SolidarityAction[]
8 | }
9 |
10 | export default async function handler (req: NextApiRequest, res: NextApiResponse) {
11 | await runMiddleware(req, res, corsGET)
12 | const solidarityActions = await getLiveSolidarityActions()
13 | res.json({ solidarityActions })
14 | }
--------------------------------------------------------------------------------
/.github/workflows/refreshWebhook.yml:
--------------------------------------------------------------------------------
1 | name: Create/Refresh Airtable Webhook for CDN Sync
2 |
3 | # Controls when the action will run. Workflow runs when manually triggered using the UI or API.
4 | on:
5 | schedule:
6 | # Run every
7 | - cron: 0 0 1-30/3 * *
8 | workflow_dispatch:
9 |
10 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel
11 | jobs:
12 | deployment:
13 | runs-on: ubuntu-latest
14 | steps:
15 | - name: Deploy Stage
16 | uses: fjogeleit/http-request-action@v1
17 | with:
18 | url: "https://gameworkersolidarity.com/api/createOrRefreshAirtableWebhook"
19 | method: "GET"
20 |
--------------------------------------------------------------------------------
/pages/api/countryData.ts:
--------------------------------------------------------------------------------
1 | import type { NextApiRequest, NextApiResponse } from 'next'
2 | import { corsGET, runMiddleware } from '../../utils/cors';
3 | import { getCountryDataByCode, CountryData } from '../../data/country';
4 |
5 | export default async function handler (req: NextApiRequest, res: NextApiResponse) {
6 | await runMiddleware(req, res, corsGET)
7 | let { iso2 } = req.query
8 | try {
9 | if (!iso2) {
10 | throw new Error("You must provide the iso2 query parameter")
11 | }
12 | const data = await getCountryDataByCode(String(iso2))
13 | res.json(data)
14 | } catch (error) {
15 | res.status(400).json({ error: error.toString() } as any)
16 | }
17 | }
--------------------------------------------------------------------------------
/data/cloudinary.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod"
2 | import { airtableCDNMapSchema } from "./schema"
3 | import { BaseRecord } from "./types"
4 |
5 | export function generateCDNMap (airtableRow: BaseRecord & { fields: { cdn_urls?: string } }) {
6 | if (!airtableRow.fields.cdn_urls) return []
7 | try {
8 | // Parse and verify the JSON we store in the Airtable
9 | const cdnMap = JSON.parse(airtableRow.fields.cdn_urls)
10 | const validation = z.array(airtableCDNMapSchema).safeParse(cdnMap)
11 | if (validation.success) {
12 | return validation.data
13 | } else {
14 | console.error(validation.error)
15 | }
16 | } catch (e) {
17 | console.error(e)
18 | }
19 | return []
20 | }
--------------------------------------------------------------------------------
/data/geo.ts:
--------------------------------------------------------------------------------
1 | import { projectStrings } from './site';
2 | import qs from 'query-string';
3 | import { OpenStreetMapReverseGeocodeResponse } from './types';
4 |
5 | export const geocodeOpenStreetMap = async (location: string, iso2: string) => {
6 | const url = qs.stringifyUrl({
7 | url: `https://nominatim.openstreetmap.org/search.php`,
8 | query: {
9 | q: location,
10 | countrycodes: iso2,
11 | format: 'jsonv2',
12 | 'accept-language': 'en-GB',
13 | limit: 1,
14 | email: projectStrings.email
15 | }
16 | })
17 | const res = await fetch(url)
18 | const data = await res.json()
19 | return data?.[0] as Promise
20 | }
--------------------------------------------------------------------------------
/pages/api/country.ts:
--------------------------------------------------------------------------------
1 | import type { NextApiRequest, NextApiResponse } from 'next'
2 | import { Country } from '../../data/types';
3 | import { corsGET, runMiddleware } from '../../utils/cors';
4 | import { getCountryByCode } from '../../data/country';
5 |
6 | export default async function handler (req: NextApiRequest, res: NextApiResponse) {
7 | await runMiddleware(req, res, corsGET)
8 | let { iso2 } = req.query
9 | try {
10 | if (!iso2) {
11 | throw new Error("You must provide the iso2 query parameter")
12 | }
13 | const data = await getCountryByCode(String(iso2))
14 | res.json(data)
15 | } catch (error) {
16 | res.status(400).json({ error: error.toString() } as any)
17 | }
18 | }
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | const nextJest = require('next/jest')
2 |
3 | const createJestConfig = nextJest({
4 | // Provide the path to your Next.js app to load next.config.js and .env files in your test environment
5 | dir: './',
6 | })
7 |
8 | // Add any custom config to be passed to Jest
9 | const customJestConfig = {
10 | setupFilesAfterEnv: ['/jest.setup.js'],
11 | moduleNameMapper: {
12 | // Handle module aliases (this will be automatically configured for you soon)
13 | '^@/components/(.*)$': '/components/$1',
14 | },
15 | }
16 |
17 | // createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async
18 | module.exports = createJestConfig(customJestConfig)
--------------------------------------------------------------------------------
/pages/404.tsx:
--------------------------------------------------------------------------------
1 | import { NextSeo } from 'next-seo';
2 | import PageLayout from '../components/PageLayout';
3 |
4 | export default function Page({ message }: { message?: string }) {
5 | message = message?.replace(/^Error:?[ ]*/, '')
6 |
7 | return (
8 |
9 |
15 |
16 |
17 |
18 | 404
19 |
20 | {message && {message} }
21 |
22 |
23 | )
24 | }
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": [
5 | "dom",
6 | "dom.iterable",
7 | "esnext"
8 | ],
9 | "allowJs": true,
10 | "skipLibCheck": true,
11 | "strict": false,
12 | "forceConsistentCasingInFileNames": true,
13 | "noEmit": true,
14 | "esModuleInterop": true,
15 | "module": "esnext",
16 | "moduleResolution": "node",
17 | "resolveJsonModule": true,
18 | "isolatedModules": true,
19 | "jsx": "preserve",
20 | "strictNullChecks": true,
21 | "incremental": true
22 | },
23 | "include": [
24 | "next-env.d.ts",
25 | "**/*.ts",
26 | "**/*.tsx"
27 | ],
28 | "exclude": [
29 | "node_modules"
30 | ]
31 | }
32 |
--------------------------------------------------------------------------------
/pages/500.tsx:
--------------------------------------------------------------------------------
1 | import { NextSeo } from 'next-seo';
2 | import { useRouter } from 'next/dist/client/router';
3 | import PageLayout from '../components/PageLayout';
4 |
5 | export default function Page({ message }: { message?: string }) {
6 | return (
7 |
8 |
14 |
15 |
16 |
17 | 500
18 |
19 | {message && {message} }
20 |
21 |
22 | )
23 | }
--------------------------------------------------------------------------------
/data/site.ts:
--------------------------------------------------------------------------------
1 | import env from 'env-var';
2 | export const projectStrings = {
3 | name: env.get('SITE_TITLE').default("Game Worker Solidarity").asString(),
4 | description: env.get('SITE_DESCRIPTION').default("Preserving and analysing the history of game worker solidarity").asString(),
5 | baseUrl: env.get('SITE_BASE_URL').default("https://gameworkersolidarity.com").asString(),
6 | twitterHandle: env.get('TWITTER_HANDLE').default('@GWSolidarity').asString(),
7 | blueskyProfile: env.get('BLUESKY_PROFILE').default('https://bsky.app/profile/gameworkersolidarity.com').asString(),
8 | email: env.get('EMAIL_ADDRESS').default('hello@gameworkersolidarity.com').asString(),
9 | github: env.get('GITHUB_REPO_URL').default('https://github.com/gameworkersolidarity/website').asString(),
10 | }
--------------------------------------------------------------------------------
/.env.template:
--------------------------------------------------------------------------------
1 | ### Required
2 | AIRTABLE_API_KEY=
3 | AIRTABLE_CDN_TABLE_ID = "tblimUv6XyFqqxG2p" // The table to monitor when syncing attachments to the CDN
4 | CLOUDINARY_NAME=
5 | CLOUDINARY_API_KEY=
6 | CLOUDINARY_API_SECRET=
7 | BASE_URL = // for webhooks; e.g. 'https://xyz.eu.ngrok.io'
8 |
9 | ### OPTIONAL
10 | # These variables have default values that point to
11 | # the Game Worker Solidarity's private Airtable.
12 | # You can safely leave all the below commented-out,
13 | # unless you want to override them
14 | #
15 | # AIRTABLE_BASE_ID=
16 | # AIRTABLE_TABLE_NAME_SOLIDARITY_ACTIONS=
17 | # AIRTABLE_TABLE_VIEW_SOLIDARITY_ACTIONS=
18 | # AIRTABLE_TABLE_VIEW_BLOG_POSTS=
19 | # AIRTABLE_TABLE_VIEW_BLOG_POSTS=
20 | # AIRTABLE_SUBMIT_EMBED_ID=
21 | # NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN=
22 | # NEXT_PUBLIC_MAPBOX_STYLE_URL=
23 | # PAGE_TTL=
--------------------------------------------------------------------------------
/utils/geo.ts:
--------------------------------------------------------------------------------
1 | import { WebMercatorViewport } from '@math.gl/web-mercator';
2 | import bbox from '@turf/bbox'
3 |
4 | export const getViewportForFeatures = (
5 | viewport: ConstructorParameters[0],
6 | addressBounds: [number, number, number, number],
7 | fitBoundsArgs: Parameters[1]
8 | ) => {
9 | // Create a calculator to generate new viewports
10 | const parsedViewport = new WebMercatorViewport(viewport);
11 | if (!addressBounds.every(n => n !== Infinity)) return
12 | const newViewport = parsedViewport.fitBounds(
13 | bboxToBounds(addressBounds as any),
14 | fitBoundsArgs
15 | );
16 | return newViewport
17 | }
18 |
19 | export const bboxToBounds = (n: [number, number, number, number]): [[number, number], [number, number]] => {
20 | return [[n[0], n[1]], [n[2], n[3]]]
21 | }
--------------------------------------------------------------------------------
/pages/api/organisingGroupsByCountry.ts:
--------------------------------------------------------------------------------
1 | import type { NextApiRequest, NextApiResponse } from 'next'
2 | import { OrganisingGroup } from '../../data/types';
3 | import { corsGET, runMiddleware } from '../../utils/cors';
4 | import { getOrganisingGroupsByCountryCode } from '../../data/organisingGroup';
5 |
6 | export type UnionsByCountryData = { unionsByCountry: OrganisingGroup[], iso2 }
7 |
8 | export default async function handler (req: NextApiRequest, res: NextApiResponse) {
9 | await runMiddleware(req, res, corsGET)
10 | let { iso2 } = req.query
11 | try {
12 | if (!iso2) {
13 | throw new Error("You must provide the iso2 query parameter")
14 | }
15 | const data = await getOrganisingGroupsByCountryCode(String(iso2))
16 | res.json({ unionsByCountry: data, iso2 })
17 | } catch (error) {
18 | res.status(400).json({ error: error.toString() } as any)
19 | }
20 | }
--------------------------------------------------------------------------------
/.github/workflows/validateAirtableData.yml:
--------------------------------------------------------------------------------
1 | name: Trigger Airtable Validation
2 |
3 | # Controls when the action will run. Workflow runs when manually triggered using the UI or API.
4 | on:
5 | schedule:
6 | # sets the action to run every 5 minutes.
7 | - cron: '*/5 * * * *'
8 | workflow_dispatch:
9 |
10 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel
11 | jobs:
12 | run_airtable_validation:
13 | # The type of runner that the job will run on
14 | runs-on: ubuntu-latest
15 |
16 | # Steps represent a sequence of tasks that will be executed as part of the job
17 | steps:
18 | # Runs a single command using the runners shell
19 | - name: Webhook
20 | uses: distributhor/workflow-webhook@69ec4d54b364f01d0be541be2ca4f826e63878d3
21 | env:
22 | webhook_url: ${{ secrets.AIRTABLE_VALIDATION_URL }}
23 | webhook_secret: ${{ secrets.AIRTABLE_VALIDATION_SECRET }}
--------------------------------------------------------------------------------
/pages/server-sitemap.xml/index.tsx:
--------------------------------------------------------------------------------
1 |
2 | import { getServerSideSitemap } from 'next-sitemap'
3 | import { GetServerSideProps } from 'next'
4 | import { actionUrl, getLiveSolidarityActions } from '../../data/solidarityAction'
5 | import { projectStrings } from '../../data/site'
6 | import { getOrganisingGroups } from '../../data/organisingGroup'
7 |
8 | export const getServerSideProps: GetServerSideProps = async (ctx) => {
9 | const groups = await getOrganisingGroups()
10 | const actions = await getLiveSolidarityActions()
11 |
12 | const fields = [
13 | ...groups.map(action => (
14 | {
15 | loc: `${projectStrings.baseUrl}/group/${action.slug}`,
16 | lastmod: new Date(action.fields.LastModified).toISOString(),
17 | }
18 | )),
19 | ...actions.map(action => (
20 | {
21 | loc: `${projectStrings.baseUrl}${actionUrl(action)}`,
22 | lastmod: new Date(action.fields.LastModified).toISOString(),
23 | }
24 | ))
25 | ]
26 |
27 | return getServerSideSitemap(ctx, fields)
28 | }
29 |
30 | export default function Sitemap () {}
--------------------------------------------------------------------------------
/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
3 |
4 |
--------------------------------------------------------------------------------
/utils/string.ts:
--------------------------------------------------------------------------------
1 | export const stringifyArray = (...ds: any[]): string => {
2 | return unique(...noNull(...ds)).map(s => s.toString().trim()).join(", ")
3 | }
4 |
5 | export const noNull = (...ds: T[]) => {
6 | return ds.filter(Boolean)
7 | }
8 |
9 | export const unique = (...ds: T[]) => {
10 | return Array.from(new Set(ds))
11 | }
12 |
13 | export const firstOf = (obj: T, keys: Array, fallbackToAny?: boolean) => {
14 | for (const key of keys) {
15 | if (obj[key] !== undefined && obj[key] !== null) return obj[key]
16 | }
17 | if (fallbackToAny) {
18 | return Object.values(obj)[0]
19 | }
20 | }
21 |
22 | export const ensureArray = (x: T | T[]): T[] => {
23 | if (Array.isArray(x)) return x
24 | return [x]
25 | }
26 |
27 | export const toggleInArray = (arr: string[], value: string) => {
28 | let newArr = JSON.parse(JSON.stringify(arr))
29 | const i = newArr.indexOf(value)
30 | let _newArr
31 | if (i > -1) {
32 | newArr.splice(i, 1)
33 | _newArr = newArr
34 | } else {
35 | _newArr = Array.from(new Set(newArr.concat([value])))
36 | }
37 | return _newArr
38 | }
--------------------------------------------------------------------------------
/pages/api/syncToCDN.ts:
--------------------------------------------------------------------------------
1 | import type { NextApiRequest, NextApiResponse } from 'next'
2 | import { syncBlogPostsToCDN, syncSolidarityActionsToCDN, uploadToCDN } from "../../data/cdn";
3 | import { runMiddleware, corsGET } from '../../utils/cors';
4 | import { getLiveSolidarityActions } from '../../data/solidarityAction';
5 | import { getBlogPosts } from '../../data/blogPost';
6 |
7 | /**
8 | * Loop through airtable records and sync their attachments to CDN
9 | */
10 | export default async function handler(req: NextApiRequest, res: NextApiResponse) {
11 | await runMiddleware(req, res, corsGET)
12 | const uploads = await Promise.all([
13 | // (async () => {
14 | // try {
15 | // const actions = await getLiveSolidarityActions()
16 | // return syncSolidarityActionsToCDN(actions)
17 | // } catch (e) {
18 | // return 0
19 | // }
20 | // })(),
21 | (async () => {
22 | try {
23 | const posts = await getBlogPosts()
24 | return syncBlogPostsToCDN(posts)
25 | } catch (e) {
26 | return 0
27 | }
28 | })()
29 | ])
30 | const uploadCount = uploads.reduce((sum, next) => sum + next, 0)
31 | return res.status(200).json(uploadCount)
32 | }
--------------------------------------------------------------------------------
/components/BlogPost.tsx:
--------------------------------------------------------------------------------
1 | import { BlogPost } from '../data/types';
2 | import Link from 'next/link';
3 | import Image from 'next/image';
4 | import { format } from 'date-fns';
5 | import { DateTime } from './Date';
6 |
7 | export function BlogPostThumbnail({ blog: b }: { blog: BlogPost }) {
8 | const image = b.cdnMap?.[0];
9 | return (
10 |
11 |
12 | {!!image && (
13 |
14 |
20 |
21 | )}
22 |
25 |
26 | {b.fields.Title}
27 |
28 |
29 | {b.fields.Summary}
30 |
31 |
32 |
33 | )
34 | }
--------------------------------------------------------------------------------
/utils/mediaQuery.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react";
2 | import { isClient } from "./environment";
3 |
4 | const getMatch = (query: string) => {
5 | return window.matchMedia(query);
6 | };
7 |
8 | const parseQueryString = (query: string) => {
9 | return query.replaceAll("@media only screen and", "").trim();
10 | };
11 |
12 | export const useMediaQuery = (query: string, defaultState = false) => {
13 | const parseAndMatch = (s: string) => getMatch(parseQueryString(s));
14 | const [state, setState] = useState(isClient ? () => parseAndMatch(query).matches : defaultState);
15 |
16 | useEffect(() => {
17 | let mounted = true;
18 | const mql = parseAndMatch(query);
19 |
20 | const onChange = () => {
21 | if (!mounted) return;
22 | setState(!!mql.matches);
23 | };
24 |
25 | if (mql.addEventListener) {
26 | mql.addEventListener("change", onChange);
27 | } else {
28 | mql.addListener(onChange); // iOS 13 and below
29 | }
30 |
31 | setState(mql.matches);
32 |
33 | return () => {
34 | mounted = false;
35 |
36 | if (mql.removeEventListener) {
37 | mql.removeEventListener("change", onChange);
38 | } else {
39 | mql.removeListener(onChange); // iOS 13 and below
40 | }
41 | };
42 | }, [query]);
43 |
44 | return state;
45 | };
--------------------------------------------------------------------------------
/utils/screens.ts:
--------------------------------------------------------------------------------
1 | import { theme } from 'twin.macro'
2 |
3 | /**
4 | * NOTE: Keep this in sync with the (custom) Tailwind theme `screens` config.
5 | * @see https://tailwindcss.com/docs/breakpoints
6 | */
7 | export type Screen = "sm" | "md" | "lg" | "xl" | "2xl";
8 | export const screens = theme`screens`
9 |
10 | // The maximum value is calculated as the minimum of the next one less 0.02px.
11 | // @see https://www.w3.org/TR/mediaqueries-4/#mq-min-max
12 | const getNextBpValue = (bp: string) => {
13 | return `${parseInt(bp) - 0.02}px`;
14 | };
15 |
16 | export const up = (bp: Screen) => {
17 | const screen = screens[bp];
18 | return `@media only screen and (min-width: ${screen})`;
19 | };
20 |
21 | export const down = (bp: Screen) => {
22 | const screen = getNextBpValue(screens[bp]);
23 | return `@media only screen and (max-width: ${screen})`;
24 | };
25 |
26 | export const between = (bpMin: Screen, bpMax: Screen) => {
27 | const screenMin = screens[bpMin];
28 | const screenMax = getNextBpValue(screens[bpMax]);
29 | return `@media only screen and (min-width: ${screenMin}) and (max-width: ${screenMax})`;
30 | };
31 |
32 | export const only = (bp: Screen) => {
33 | const screenKeys = Object.keys(screens) as Screen[];
34 | const currentKeyIndex = screenKeys.indexOf(bp);
35 | const nextBp = screenKeys[currentKeyIndex + 1];
36 | return nextBp ? between(bp, nextBp) : up(bp);
37 | };
--------------------------------------------------------------------------------
/pages/api/revalidate.ts:
--------------------------------------------------------------------------------
1 | // 1. set REVALIDATE_SECRET_TOKEN in DO
2 | // 2. trigger webhook with REVALIDATE_SECRET_TOKEN and path
3 |
4 | import { NextApiRequest, NextApiResponse } from "next"
5 |
6 | export default async function handler(req: NextApiRequest, res: NextApiResponse) {
7 | const secret = req.query.secret?.toString()
8 | const path = req.query.path?.toString()
9 |
10 | // Check for secret to confirm this is a valid request
11 | if (!secret || secret !== process.env.REVALIDATE_SECRET_TOKEN) {
12 | return res.status(401).json({ message: 'Invalid token' })
13 | }
14 |
15 | if (!path) {
16 | return res.status(400).json({ message: 'Invalid path' })
17 | }
18 |
19 | try {
20 | // this should be the actual path not a rewritten path
21 | // e.g. for "/blog/[slug]" this should be "/blog/post-1"
22 | await res.revalidate(path)
23 | // Index page lists all actions
24 | const INDEX_PAGE_PATHS = ["/action", "/group"]
25 | const indexPageContentHasChanged = INDEX_PAGE_PATHS.some(
26 | relevantPath => path.includes(relevantPath)
27 | )
28 | if (indexPageContentHasChanged) {
29 | await res.revalidate("/")
30 | }
31 | return res.json({ revalidated: true })
32 | } catch (err) {
33 | // If there was an error, Next.js will continue
34 | // to show the last successfully generated page
35 | return res.status(500).send('Error revalidating')
36 | }
37 | }
--------------------------------------------------------------------------------
/pages/feed.json.tsx:
--------------------------------------------------------------------------------
1 | import jsonfeedToRSS from 'jsonfeed-to-rss'
2 | import { getBlogPosts } from '../data/blogPost';
3 | import { formatRFC3339 } from 'date-fns';
4 |
5 | // https://jsonfeed.org/version/1.1
6 | export const generateJSONFeed = async () => {
7 | const articles = getBlogPosts()
8 | return {
9 | "version": "https://jsonfeed.org/version/1",
10 | "title": "Game Worker Solidarity",
11 | "home_page_url": "https://gameworkersolidarity.com",
12 | "description": "Preserving the history of video game worker solidarity",
13 | "feed_url": "http://gameworkersolidarity.com/feed.json",
14 | "items": (await articles).map(article => ({
15 | "title": article.fields.Title,
16 | "summary": article.fields.Summary,
17 | "date_published": formatRFC3339(new Date(article.fields.Date)),
18 | "content_html": article.body.html,
19 | "url": `https://gameworkersolidarity.com/analysis/${article.fields.Slug}`,
20 | "id": `https://gameworkersolidarity.com/analysis/${article.fields.Slug}`,
21 | }))
22 | }
23 | }
24 |
25 | export default function Page() {
26 | return null
27 | }
28 |
29 | export async function getServerSideProps(context) {
30 | const res = context.res;
31 | if (!res) {
32 | return;
33 | }
34 | const feed = await generateJSONFeed()
35 | res.setHeader("Content-Type", "application/feed+json");
36 | res.write(JSON.stringify(feed));
37 | res.end();
38 | }
--------------------------------------------------------------------------------
/__tests__/airtable.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * @jest-environment jsdom
3 | */
4 |
5 | import React from 'react'
6 | import { render, screen } from '@testing-library/react'
7 | import { validateAirtableAction } from '../data/airtableValidation';
8 |
9 | const dummyAirtableRecord = (fields) => ({ fields, id: 'test', createdTime: 'test' });
10 |
11 | describe('Airtable validation', () => {
12 | it('passes good URL slugs', () => {
13 | const testActions = [
14 | dummyAirtableRecord({ slug: "home" }),
15 | dummyAirtableRecord({ slug: "2020-01-person-eats-pie" }),
16 | dummyAirtableRecord({ slug: "2004-11-ea_spouse-open-letter" }),
17 | ]
18 | for (const testAction of testActions) {
19 | const result = validateAirtableAction(testAction);
20 | expect(result).toBeTruthy()
21 | }
22 | })
23 |
24 | it('fails bad URL slugs', () => {
25 | const testActions = [
26 | dummyAirtableRecord({ slug: " home " }),
27 | dummyAirtableRecord({ slug: "2020/01/01-person-eats-pie" }),
28 | dummyAirtableRecord({ slug: "-2020-01-01-person-eats-pie" }),
29 | dummyAirtableRecord({ slug: "_2020-01-01-person-eats-pie" }),
30 | dummyAirtableRecord({ slug: "2020-01-01-person-eats-pie-" }),
31 | dummyAirtableRecord({ slug: "2020-01-01-person-eats-pie_" }),
32 | ]
33 | for (const testAction of testActions) {
34 | const result = validateAirtableAction(testAction);
35 | expect(result).toBeFalsy()
36 | }
37 | })
38 | })
--------------------------------------------------------------------------------
/pages/analysis.tsx:
--------------------------------------------------------------------------------
1 | import { BlogPost } from '../data/types';
2 | import { format } from 'date-fns';
3 | import { getBlogPosts } from '../data/blogPost';
4 | import { NextSeo } from 'next-seo';
5 | import env from 'env-var';
6 | import { GetStaticProps } from 'next';
7 | import PageLayout from '../components/PageLayout';
8 | import Image from 'next/image'
9 | import Link from 'next/link';
10 | import { BlogPostThumbnail } from '../components/BlogPost';
11 |
12 | type Props = {
13 | blogPosts: BlogPost[],
14 | };
15 |
16 | export default function Page({ blogPosts }: Props) {
17 | return (
18 |
19 |
25 |
26 |
27 |
28 | Analysis
29 |
30 |
31 |
32 |
33 |
34 | {blogPosts.map(b => (
35 |
36 | ))}
37 |
38 |
39 |
40 | )
41 | }
42 |
43 | export const getStaticProps: GetStaticProps = async (context) => {
44 | return {
45 | props: {
46 | blogPosts: await getBlogPosts() || []
47 | },
48 | revalidate: env.get('PAGE_TTL').default(
49 | env.get('NODE_ENV').asString() === 'production' ? 60 : 5
50 | ).asInt(), // In seconds
51 | }
52 | }
--------------------------------------------------------------------------------
/components/KonamiCode.tsx:
--------------------------------------------------------------------------------
1 | import { Dialog } from '@headlessui/react';
2 | import { useState } from 'react';
3 | import { useKonami } from 'react-konami-code';
4 | import Emoji from 'a11y-react-emoji';
5 |
6 | export function KonamiCode () {
7 | const [open, setOpen] = useState(false)
8 | useKonami(() => setOpen(true))
9 | const close = () => setOpen(false)
10 |
11 | return (
12 |
13 |
14 |
15 |
20 | ← Back
21 |
22 |
23 |
24 | You found the easter egg!
25 |
26 |
27 | We weren't creative enough to actually make any content for the easter egg, but there you are!
28 | (The real easter egg is the solidarity you build with your coworkers.)
29 |
30 |
Close
31 |
32 |
33 |
34 | )
35 | }
--------------------------------------------------------------------------------
/.github/pull_request_template:
--------------------------------------------------------------------------------
1 |
2 |
3 | ## Description, Motivation and Context
4 |
5 |
6 |
7 |
8 |
9 | ## Testing / QA plan
10 |
11 |
12 |
13 |
14 | ## Screenshots (if appropriate)
15 |
16 | ## Types of changes
17 |
18 | - [ ] Bug fix (non-breaking change which fixes an issue)
19 | - [ ] New feature (non-breaking change which adds functionality)
20 | - [ ] Breaking change (fix or feature that would cause existing functionality to change)
21 |
22 | ## Checklist:
23 |
24 |
25 | - [ ] I've checked the spec (e.g. Figma file) and documented any divergences.
26 | - [ ] My code follows the code style of this project.
27 | - [ ] My change requires a change to the documentation.
28 | - [ ] I've updated the documentation accordingly.
--------------------------------------------------------------------------------
/pages/submit.tsx:
--------------------------------------------------------------------------------
1 | import env from 'env-var';
2 | import qs from 'query-string'
3 | import { NextSeo } from 'next-seo';
4 | import { GetStaticProps } from 'next';
5 | import PageLayout from '../components/PageLayout';
6 | import Script from 'next/script';
7 |
8 | export default function Page() {
9 | return (
10 |
11 |
17 |
18 |
19 |
20 | Submit a solidarity action to the timeline
21 |
22 |
23 |
24 |
25 |
26 | )
27 | }
28 |
29 | const EMBED_URL = qs.stringifyUrl({
30 | url: `https://airtable.com/embed/${env.get('AIRTABLE_SUBMIT_EMBED_ID').default('shrghSX8tcj2XwhqO').asString()}`,
31 | query: {
32 | backgroundColor: 'red'
33 | }
34 | })
35 |
36 | function AirtableEmbed ({ url }) {
37 | return (
38 | <>
39 |
40 |
47 | >
48 | )
49 | }
50 |
51 | export const getStaticProps: GetStaticProps = async (context) => {
52 | return {
53 | props: {},
54 | revalidate: env.get('PAGE_TTL').default(
55 | env.get('NODE_ENV').asString() === 'production' ? 60 : 5
56 | ).asInt() // In seconds
57 | }
58 | }
--------------------------------------------------------------------------------
/components/Filter.tsx:
--------------------------------------------------------------------------------
1 | import cx from 'classnames';
2 | import pluralize from 'pluralize';
3 |
4 | export function FilterButton ({
5 | label = 'Select',
6 | selectionCount,
7 | isOpen
8 | }: {
9 | label?: string
10 | selectionCount?: number
11 | isOpen?: boolean
12 | }) {
13 | const hasSelections = !!selectionCount
14 | return (
15 |
27 | {!selectionCount ? label : pluralize(label, selectionCount, true)}
28 |
29 |
30 | ▾
31 |
32 |
33 | )
34 | }
35 |
36 | type HeadlessUiListBoxOptionArgs = {
37 | active: boolean;
38 | selected: boolean;
39 | disabled: boolean;
40 | }
41 |
42 | export function FilterOption ({
43 | children,
44 | active,
45 | selected,
46 | disabled
47 | }: {
48 | children?: any
49 | } & HeadlessUiListBoxOptionArgs) {
50 | return (
51 | {children}
58 | )
59 | }
--------------------------------------------------------------------------------
/pages/api/validateAirtableData.ts:
--------------------------------------------------------------------------------
1 | import type { NextApiRequest, NextApiResponse } from 'next'
2 | import { Country, SolidarityAction, SolidarityActionAirtableRecord } from '../../data/types';
3 | import { corsGET, runMiddleware } from '../../utils/cors';
4 | import { getAllSolidarityActions, solidarityActionBase } from '../../data/solidarityAction';
5 | import { validateAirtableAction } from '../../data/airtableValidation';
6 | import { chunk } from 'lodash'
7 |
8 | export default async function handler (req: NextApiRequest, res: NextApiResponse) {
9 | try {
10 | await runMiddleware(req, res, corsGET)
11 | const actions = await getAllSolidarityActions()
12 | const updateList: Array<{
13 | id: SolidarityActionAirtableRecord['id'],
14 | fields: Partial
15 | }> = []
16 | for (const airtableRecord of actions) {
17 | const newValidStatus = validateAirtableAction(airtableRecord)
18 | if (newValidStatus != airtableRecord.fields.hasPassedValidation) {
19 | updateList.push({
20 | id: airtableRecord.id,
21 | fields: { hasPassedValidation: newValidStatus }
22 | })
23 | }
24 | }
25 | let recordsUpdated = 0
26 | for (const chunkedUpdate of chunk(updateList, 10)) {
27 | recordsUpdated += (await update(chunkedUpdate)).length
28 | }
29 | return res.status(200).json({ recordsUpdated })
30 | } catch (error) {
31 | res.status(400).json({ error: error.toString() } as any)
32 | // TODO: Trigger Slack / Github Action error
33 | }
34 | }
35 |
36 | async function update (updates: any[]) {
37 | return new Promise((resolve, reject) => {
38 | solidarityActionBase().update(updates, function(err, records) {
39 | if (err) {
40 | console.error(err)
41 | throw new Error(err)
42 | reject(err)
43 | }
44 | resolve(records)
45 | });
46 | })
47 | }
--------------------------------------------------------------------------------
/styles/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | html,
6 | body {
7 | @apply min-h-screen bg-gwBackground max-w-full;
8 | }
9 |
10 | @font-face {
11 | font-family: 'Parabole';
12 | src: url('/fonts/Parabole-Regular.woff2') format('woff2'),
13 | url('/fonts/Parabole-Regular.woff') format('woff'),
14 | url('/fonts/Parabole-Regular.ttf') format('truetype');
15 | font-weight: normal;
16 | font-style: normal;
17 | font-display: swap;
18 | }
19 |
20 | @layer utilities {
21 | .capitalize-first:first-letter {
22 | text-transform: uppercase;
23 | }
24 | }
25 |
26 | @layer components {
27 | .link {
28 | @apply underline cursor-pointer font-semibold
29 | }
30 |
31 | .prose a {
32 | color: inherit;
33 | }
34 |
35 | .prose a:hover {
36 | color: inherit;
37 | }
38 |
39 | .button {
40 | @apply cursor-pointer inline-block px-2 py-1 border-2 border-gwPink rounded-xl hover:bg-gwPink hover:text-white transition duration-75 mt-2
41 | }
42 |
43 | .content-wrapper {
44 | @apply mx-auto px-4 lg:px-5 xl:px-7;
45 | }
46 |
47 | .filter-item {
48 | @apply inline-block mr-2 mb-2;
49 |
50 | &:last-child {
51 | @apply mr-0 mb-0;
52 | }
53 | }
54 |
55 | .listbox-dropdown {
56 | @apply border-2 border-gwPink overflow-y-auto rounded-lg rounded-tl-none bg-white absolute z-40 shadow-gwPink;
57 | margin-top: -2px;
58 | max-height: 33vh;
59 | height: 400;
60 | }
61 |
62 | .glowable {
63 | @apply shadow-noglow hover:shadow-glow transition duration-100;
64 | }
65 |
66 | .action-chart svg {
67 | @apply overflow-visible;
68 | }
69 |
70 | .country-popup .mapboxgl-popup-content{
71 | @apply shadow-gwOrange rounded-lg p-0;
72 | }
73 |
74 | .nav-link {
75 | @apply font-semibold underline transition-all duration-75 bg-transparent hover:bg-white hover:shadow-innerGwPink rounded-lg inline-block px-3 -mx-2 md:-mx-1 py-2;
76 | }
77 | }
--------------------------------------------------------------------------------
/pages/data.tsx:
--------------------------------------------------------------------------------
1 | import env from 'env-var'
2 | import MarkdownIt from 'markdown-it'
3 | import { GetStaticProps } from 'next'
4 | import { NextSeo } from 'next-seo'
5 | import PageLayout from '../components/PageLayout'
6 | import { projectStrings } from '../data/site'
7 | export const markdown = new MarkdownIt()
8 |
9 | export default function Page({ introHTML }: { introHTML: string }) {
10 | return (
11 |
12 |
18 |
19 |
20 |
21 | Get the Data
22 |
23 |
27 |
28 |
29 |
30 | )
31 | }
32 |
33 | const EMBED_URL = `https://airtable.com/embed/${env.get('AIRTABLE_DATA_EMBED_ID').default('shrxKvrGsmARoz6eY').asString()}?backgroundColor=red&viewControls=on`
34 |
35 | function AirtableDataEmbed({ url }) {
36 | return (
37 |
43 | )
44 | }
45 |
46 | const introHTML = markdown.render(`
47 | We provide the full dataset of actions as:
48 |
49 | - a CSV file via Airtable (see below)
50 | - a public API at [GET /api/solidarityActions](/api/solidarityActions)
51 |
52 | There are no limitations on the use of this data at present. Please use this data for your solidarity projects and [tell us](mailto:${projectStrings.email}), we'd love to hear about them!
53 | `)
54 |
55 | export const getStaticProps: GetStaticProps = async (context) => {
56 | return {
57 | props: {
58 | introHTML
59 | },
60 | revalidate: env.get('PAGE_TTL').default(
61 | env.get('NODE_ENV').asString() === 'production' ? 60 : 5
62 | ).asInt() // In seconds
63 | }
64 | }
--------------------------------------------------------------------------------
/pages/index.tsx:
--------------------------------------------------------------------------------
1 | import { getLiveSolidarityActions } from '../data/solidarityAction';
2 | import { SolidarityAction, Company, Category, Country, OrganisingGroup } from '../data/types';
3 | import env from 'env-var';
4 | import { GetStaticProps } from 'next';
5 | import PageLayout from '../components/PageLayout';
6 | import { getCompanies } from '../data/company';
7 | import { getCategories } from '../data/category';
8 | import { getCountries } from '../data/country';
9 | import { SolidarityActionsTimeline } from '../components/Timeline';
10 | import { getOrganisingGroups } from '../data/organisingGroup';
11 | import { createContext } from 'react';
12 |
13 | type PageProps = {
14 | actions: SolidarityAction[],
15 | companies: Company[],
16 | categories: Category[],
17 | countries: Country[],
18 | groups: OrganisingGroup[]
19 | }
20 |
21 | export const ActionsContext = createContext({
22 | actions: [],
23 | companies: [],
24 | categories: [],
25 | countries: [],
26 | groups: []
27 | })
28 |
29 | export default function Page({ actions, companies, categories, countries, groups }: PageProps) {
30 | return (
31 |
32 | {/* */}
39 |
46 | {/* */}
47 |
48 | )
49 | }
50 |
51 | export const getStaticProps: GetStaticProps<
52 | PageProps,
53 | {}
54 | > = async (context) => {
55 | return {
56 | props: {
57 | actions: await getLiveSolidarityActions(),
58 | companies: await getCompanies(),
59 | categories: await getCategories(),
60 | countries: await getCountries(),
61 | groups: await getOrganisingGroups()
62 | },
63 | revalidate: env.get('PAGE_TTL').default(
64 | env.get('NODE_ENV').asString() === 'production' ? 60 : 5
65 | ).asInt(), // In seconds
66 | }
67 | }
--------------------------------------------------------------------------------
/pages/_app.tsx:
--------------------------------------------------------------------------------
1 | import { DefaultSeo } from 'next-seo';
2 | import { useRouter } from 'next/router';
3 | import { useEffect } from 'react';
4 | import { SWRConfig } from 'swr';
5 | import { KonamiCode } from '../components/KonamiCode';
6 | import { useCanonicalURL } from '../data/seo';
7 | import { projectStrings } from '../data/site';
8 | import '../styles/globals.css';
9 | import { doNotFetch } from '../utils/swr';
10 |
11 | export const defaultOGImageStack = [
12 | {
13 | url: projectStrings.baseUrl + `/images/game-workers-share-card-new.png`,
14 | alt: 'Game Worker Solidarity',
15 | width: 955,
16 | height: 500
17 | }
18 | ]
19 |
20 | function MyApp({ Component, pageProps, headerLinks, footerLinks }) {
21 | const canonicalURL = useCanonicalURL()
22 |
23 | return (
24 |
28 |
53 |
54 |
55 |
56 |
57 |
58 | )
59 | }
60 |
61 | export default MyApp
62 |
--------------------------------------------------------------------------------
/styles/Home.module.css:
--------------------------------------------------------------------------------
1 | .container {
2 | min-height: 100vh;
3 | padding: 0 0.5rem;
4 | display: flex;
5 | flex-direction: column;
6 | justify-content: center;
7 | align-items: center;
8 | height: 100vh;
9 | }
10 |
11 | .main {
12 | padding: 5rem 0;
13 | flex: 1;
14 | display: flex;
15 | flex-direction: column;
16 | justify-content: center;
17 | align-items: center;
18 | }
19 |
20 | .footer {
21 | width: 100%;
22 | height: 100px;
23 | border-top: 1px solid #eaeaea;
24 | display: flex;
25 | justify-content: center;
26 | align-items: center;
27 | }
28 |
29 | .footer a {
30 | display: flex;
31 | justify-content: center;
32 | align-items: center;
33 | flex-grow: 1;
34 | }
35 |
36 | .title a {
37 | color: #0070f3;
38 | text-decoration: none;
39 | }
40 |
41 | .title a:hover,
42 | .title a:focus,
43 | .title a:active {
44 | text-decoration: underline;
45 | }
46 |
47 | .title {
48 | margin: 0;
49 | line-height: 1.15;
50 | font-size: 4rem;
51 | }
52 |
53 | .title,
54 | .description {
55 | text-align: center;
56 | }
57 |
58 | .description {
59 | line-height: 1.5;
60 | font-size: 1.5rem;
61 | }
62 |
63 | .code {
64 | background: #fafafa;
65 | border-radius: 5px;
66 | padding: 0.75rem;
67 | font-size: 1.1rem;
68 | font-family: Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono,
69 | Bitstream Vera Sans Mono, Courier New, monospace;
70 | }
71 |
72 | .grid {
73 | display: flex;
74 | align-items: center;
75 | justify-content: center;
76 | flex-wrap: wrap;
77 | max-width: 800px;
78 | margin-top: 3rem;
79 | }
80 |
81 | .card {
82 | margin: 1rem;
83 | padding: 1.5rem;
84 | text-align: left;
85 | color: inherit;
86 | text-decoration: none;
87 | border: 1px solid #eaeaea;
88 | border-radius: 10px;
89 | transition: color 0.15s ease, border-color 0.15s ease;
90 | width: 45%;
91 | }
92 |
93 | .card:hover,
94 | .card:focus,
95 | .card:active {
96 | color: #0070f3;
97 | border-color: #0070f3;
98 | }
99 |
100 | .card h2 {
101 | margin: 0 0 1rem 0;
102 | font-size: 1.5rem;
103 | }
104 |
105 | .card p {
106 | margin: 0;
107 | font-size: 1.25rem;
108 | line-height: 1.5;
109 | }
110 |
111 | .logo {
112 | height: 1em;
113 | margin-left: 0.5rem;
114 | }
115 |
116 | @media (max-width: 600px) {
117 | .grid {
118 | width: 100%;
119 | flex-direction: column;
120 | }
121 | }
122 |
--------------------------------------------------------------------------------
/pages/[...slug].tsx:
--------------------------------------------------------------------------------
1 | import { getSingleStaticPage, getStaticPages } from '../data/staticPage';
2 | import { StaticPage } from '../data/types';
3 | import { NextSeo } from 'next-seo';
4 | import env from 'env-var';
5 | import { GetStaticPaths, GetStaticProps } from 'next';
6 | import PageLayout from '../components/PageLayout';
7 | import ErrorPage from './404'
8 |
9 | type PageProps = { article: StaticPage | null, errorMessage?: string }
10 | type PageParams = { slug: string[] }
11 |
12 | export default function Page({ article, errorMessage }: PageProps) {
13 | if (!article) return
14 |
15 | return (
16 |
17 |
25 |
26 |
27 |
28 | {article.fields.Title}
29 | {article.fields.Summary}
30 |
31 |
32 |
33 |
34 | )
35 | }
36 |
37 | export const getStaticPaths: GetStaticPaths = async (context) => {
38 | const links = (await getStaticPages()).filter(page =>
39 | typeof page.fields.Slug === 'string'
40 | && page.fields.Slug !== 'about'
41 | )
42 | return {
43 | paths: links.map(page => ({
44 | params: {
45 | slug: page.fields.Slug!.split('/')
46 | }
47 | })),
48 | fallback: true
49 | }
50 | }
51 |
52 | export const getStaticProps: GetStaticProps<
53 | PageProps, PageParams
54 | > = async (context) => {
55 | if (!context?.params?.slug) throw new Error()
56 |
57 | let article
58 | let errorMessage = ''
59 | try {
60 | article = await getSingleStaticPage(context.params.slug.join('/')) || null
61 | } catch (e) {
62 | console.error("No article was found", e)
63 | article = null
64 | errorMessage = e.toString()
65 | }
66 |
67 | return {
68 | props: {
69 | article,
70 | errorMessage
71 | },
72 | revalidate: env.get('PAGE_TTL').default(
73 | env.get('NODE_ENV').asString() === 'production' ? 60 : 5
74 | ).asInt(), // In seconds
75 | }
76 | }
--------------------------------------------------------------------------------
/data/staticPage.ts:
--------------------------------------------------------------------------------
1 | import { StaticPage } from './types';
2 | import env from 'env-var';
3 | import { airtableBase } from './airtable';
4 | import { parseMarkdown } from './markdown';
5 | import { staticPageSchema } from './schema';
6 |
7 | export const formatStaticPage = (staticPage: StaticPage): StaticPage => {
8 | staticPage.body = parseMarkdown(staticPage.fields.Body || '')
9 |
10 | try {
11 | // Remove any keys not expected by the parser
12 | staticPage = staticPageSchema.parse(staticPage)
13 | } catch(e) {
14 | console.error(JSON.stringify(staticPage), e)
15 | }
16 | return staticPage
17 | }
18 |
19 | export const staticPageBase = () => airtableBase()(
20 | env.get('AIRTABLE_TABLE_NAME_STATIC_PAGES').default('Static Pages').asString()
21 | )
22 |
23 | const fields: Array = ['Title', 'Summary', 'Slug', 'Body', 'Public']
24 |
25 | export async function getStaticPages (): Promise> {
26 | return new Promise((resolve, reject) => {
27 | const staticPages: StaticPage[] = []
28 |
29 | staticPageBase().select({
30 | filterByFormula: 'AND(Public, Title!="")',
31 | fields,
32 | maxRecords: 1000,
33 | view: env.get('AIRTABLE_TABLE_VIEW_STATIC_PAGES').default('All Pages').asString(),
34 | }).eachPage(function page(records, fetchNextPage) {
35 | try {
36 | records.forEach(function(record) {
37 | staticPages.push(record._rawJson)
38 | });
39 | fetchNextPage();
40 | } catch(e) {
41 | reject(e)
42 | }
43 | }, function done(err) {
44 | try {
45 | if (err) { reject(err); return; }
46 | resolve(staticPages)
47 | } catch (e) {
48 | reject(e)
49 | }
50 | });
51 | })
52 | }
53 |
54 | export async function getSingleStaticPage (slug: string) {
55 | return new Promise((resolve, reject) => {
56 | staticPageBase().select({
57 | filterByFormula: `AND(Public, Slug="${slug}")`,
58 | fields,
59 | maxRecords: 1,
60 | view: env.get('AIRTABLE_TABLE_VIEW_STATIC_PAGES').default('All Pages').asString(),
61 | }).firstPage((error, records) => {
62 | try {
63 | if (error) console.error(error)
64 | if (error || !records?.length) {
65 | return reject(`There's no page here`)
66 | }
67 | return resolve(formatStaticPage(records[0]._rawJson))
68 | } catch (e) {
69 | reject(e)
70 | }
71 | })
72 | })
73 | }
--------------------------------------------------------------------------------
/pages/group/[groupId].tsx:
--------------------------------------------------------------------------------
1 | import { getSingleOrganisingGroup, getOrganisingGroups, groupUrl } from '../../data/organisingGroup';
2 | import { OrganisingGroup } from '../../data/types';
3 | import Link from 'next/link';
4 | import env from 'env-var';
5 | import { GetStaticPaths, GetStaticProps } from 'next';
6 | import ErrorPage from '../404'
7 | import PageLayout from '../../components/PageLayout';
8 | import { OrganisingGroupCard, OrganisingGroupSEO } from '../../components/OrganisingGroup';
9 | import { useRouter } from 'next/router';
10 | import { useEffect } from 'react';
11 |
12 | type PageProps = { group: OrganisingGroup | null, errorMessage?: string }
13 | type PageParams = { groupId: string }
14 |
15 | export default function Page({ group, errorMessage }: PageProps) {
16 | const router = useRouter()
17 | if (!group) return
18 | useEffect(() => {
19 | // The user may have landed on the Airtable ID url rather than the canonical slugified URL
20 | const prettyURL = groupUrl(group)
21 | if (!router.asPath.includes(prettyURL)) {
22 | router.replace(prettyURL, undefined, { shallow: true })
23 | }
24 | }, [group, router])
25 |
26 | return (
27 |
28 |
29 |
34 |
35 | )
36 | }
37 |
38 | export const getStaticPaths: GetStaticPaths = async (context) => {
39 | const links = await getOrganisingGroups()
40 | return {
41 | paths: links.map(page => ({
42 | params: {
43 | groupId: page.slug
44 | }
45 | })),
46 | fallback: true
47 | }
48 | }
49 |
50 | export const getStaticProps: GetStaticProps<
51 | PageProps, PageParams
52 | > = async (context) => {
53 | if (!context?.params?.groupId) throw new Error()
54 |
55 | let group
56 | let errorMessage = ''
57 | try {
58 | group = await getSingleOrganisingGroup(context.params.groupId) || null
59 | } catch (e) {
60 | console.error("No group was found", e)
61 | errorMessage = e.toString()
62 | group = null
63 | }
64 |
65 | return {
66 | props: {
67 | group,
68 | errorMessage
69 | },
70 | revalidate: env.get('PAGE_TTL').default(
71 | env.get('NODE_ENV').asString() === 'production' ? 60 : 5
72 | ).asInt(), // In seconds
73 | }
74 | }
--------------------------------------------------------------------------------
/pages/action/[actionId].tsx:
--------------------------------------------------------------------------------
1 | import { actionUrl, getSingleSolidarityAction, getLiveSolidarityActions } from '../../data/solidarityAction';
2 | import { SolidarityAction } from '../../data/types';
3 | import { SolidarityActionCard } from '../../components/SolidarityActions';
4 | import Link from 'next/link';
5 | import env from 'env-var';
6 | import { GetStaticPaths, GetStaticProps } from 'next';
7 | import ErrorPage from '../404'
8 | import PageLayout from '../../components/PageLayout';
9 | import { useEffect } from 'react';
10 | import { useRouter } from 'next/router';
11 |
12 | type PageProps = { action: SolidarityAction | null, errorMessage?: string }
13 | type PageParams = { actionId: string, page?: string }
14 |
15 | export default function Page({ action, errorMessage }: PageProps) {
16 | const router = useRouter()
17 | if (!action) return
18 | useEffect(() => {
19 | // The user may have landed on the Airtable ID url rather than the canonical slugified URL
20 | const prettyURL = actionUrl(action)
21 | if (!router.asPath.includes(prettyURL)) {
22 | router.replace(prettyURL, undefined, { shallow: true })
23 | }
24 | }, [action, router])
25 |
26 | return (
27 |
28 |
36 |
37 | )
38 | }
39 |
40 | export const getStaticPaths: GetStaticPaths = async (context) => {
41 | const links = await getLiveSolidarityActions()
42 | return {
43 | paths: links.map(page => ({
44 | params: {
45 | actionId: page.slug,
46 | page: JSON.stringify(page)
47 | }
48 | })),
49 | fallback: true
50 | }
51 | }
52 |
53 | export const getStaticProps: GetStaticProps<
54 | PageProps, PageParams
55 | > = async (context) => {
56 | if (!context?.params?.actionId) throw new Error()
57 |
58 | let action
59 | let errorMessage = ''
60 | try {
61 | if (context.params.page) {
62 | action = JSON.parse(context.params.page)
63 | } else {
64 | action = await getSingleSolidarityAction(context.params.actionId) || null
65 | }
66 | } catch (e) {
67 | console.error("No action was found", e)
68 | errorMessage = e.toString()
69 | action = null
70 | }
71 |
72 | return {
73 | props: {
74 | action,
75 | errorMessage
76 | },
77 | revalidate: env.get('PAGE_TTL').default(
78 | env.get('NODE_ENV').asString() === 'production' ? 60 : 5
79 | ).asInt(), // In seconds
80 | }
81 | }
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "game-worker-solidarity-project",
3 | "version": "0.1.0",
4 | "private": true,
5 | "cacheDirectories": [
6 | "node_modules/",
7 | ".next/cache/"
8 | ],
9 | "scripts": {
10 | "dev": "next dev",
11 | "build": "next build && next-sitemap",
12 | "start": "next start -H 0.0.0.0 -p ${PORT:-8080}",
13 | "generateschema": "ts-to-zod data/types.ts data/schema.ts",
14 | "test": "jest --watch"
15 | },
16 | "dependencies": {
17 | "@headlessui/react": "^1.3.0",
18 | "@heroicons/react": "^1.0.1",
19 | "@math.gl/web-mercator": "^3.4.3",
20 | "@react-hook/window-scroll": "^1.3.0",
21 | "@tailwindcss/typography": "^0.4.1",
22 | "@testing-library/jest-dom": "^5.16.1",
23 | "@testing-library/react": "^12.1.2",
24 | "@turf/bbox": "^6.4.0",
25 | "@turf/combine": "^6.4.0",
26 | "@turf/helpers": "^6.4.0",
27 | "@urbica/react-map-gl": "^1.14.3",
28 | "@urbica/react-map-gl-cluster": "^0.2.0",
29 | "@visx/visx": "^1.12.0",
30 | "a11y-react-emoji": "^1.1.3",
31 | "airtable": "^0.11.1",
32 | "autoprefixer": "^10.2.6",
33 | "babel-plugin-macros": "^3.1.0",
34 | "babel-plugin-polished": "^1.1.0",
35 | "classnames": "^2.3.1",
36 | "cloudinary": "^1.33.0",
37 | "cors": "^2.8.5",
38 | "country-coords": "^1.0.0",
39 | "country-flag-emoji": "^1.0.3",
40 | "country-to-bbox": "https://github.com/austinkelmore/country-to-bbox/tarball/patch-1",
41 | "d3": "6",
42 | "date-fns": "^2.22.1",
43 | "env-var": "^7.0.1",
44 | "fuse.js": "^6.4.6",
45 | "highlight-words-core": "^1.2.2",
46 | "jest": "^27.4.5",
47 | "jsonfeed-to-rss": "^3.0.5",
48 | "jsonpatch": "^3.0.1",
49 | "just-diff": "^3.1.1",
50 | "lodash.groupby": "^4.6.0",
51 | "lodash.isequal": "^4.5.0",
52 | "mapbox-gl": "^2.3.1",
53 | "markdown-it": "^12.0.6",
54 | "next": "12",
55 | "next-seo": "^4.24.0",
56 | "next-sitemap": "^1.6.108",
57 | "next-use-contextual-routing": "^2.0.0",
58 | "pluralize": "^8.0.0",
59 | "polished": "^4.1.3",
60 | "postcss": "^8.3.0",
61 | "query-string": "^7.0.0",
62 | "react": "^17.0.2",
63 | "react-dom": "^17.0.2",
64 | "react-highlight-words": "^0.17.0",
65 | "react-konami-code": "^2.2.2",
66 | "react-spring": "^9.2.3",
67 | "react-test-renderer": "^17.0.2",
68 | "remove-markdown": "^0.3.0",
69 | "scroll-into-view": "^1.16.0",
70 | "supercluster": "^7.1.3",
71 | "swr": "^0.5.6",
72 | "tailwindcss": "^2.2.4",
73 | "twin.macro": "^2.5.0",
74 | "typescript": "^4.3.2",
75 | "use-debounce": "^7.0.0",
76 | "zod": "^3.2.0"
77 | },
78 | "devDependencies": {
79 | "@types/d3": "^6.7.0",
80 | "@types/mapbox-gl": "^2.3.3",
81 | "@types/markdown-it": "^12.0.1",
82 | "@types/react": "^17.0.9",
83 | "@types/react-highlight-words": "^0.16.2",
84 | "@types/supercluster": "^5.0.3",
85 | "ts-to-zod": "^3.1.3"
86 | }
87 | }
88 |
--------------------------------------------------------------------------------
/pages/api/createOrRefreshAirtableWebhook.ts:
--------------------------------------------------------------------------------
1 | import type {
2 | NextApiRequest,
3 | NextApiResponse
4 | } from 'next'
5 | import {
6 | corsGET,
7 | runMiddleware
8 | } from '../../utils/cors';
9 | import {
10 | getCountryDataByCode,
11 | CountryData
12 | } from '../../data/country';
13 | import env from 'env-var';
14 | import isEqual from 'lodash.isequal';
15 |
16 | export default async function handler(req: NextApiRequest, res: NextApiResponse) {
17 | await runMiddleware(req, res, corsGET)
18 |
19 | // List Airtable webhooks
20 | // curl "https://api.airtable.com/v0/bases/{baseID}/webhooks" -H "Authorization: Bearer YOUR_TOKEN"
21 | const baseID = env.get('AIRTABLE_BASE_ID').default('appeAmlnDhmq6QSDi').required().asString()
22 | const tableID = env.get('AIRTABLE_CDN_TABLE_ID').default('tblimUv6XyFqqxG2p').required().asString()
23 | const apiKey = env.get('AIRTABLE_API_KEY').required().asString()
24 | const baseURL = env.get('BASE_URL').required().asString()
25 |
26 | // Get a ping whenever solidarity actions are updated
27 | // Docs: https://airtable.com/developers/web/api/model/webhooks-specification
28 | const webhookSpecification = {
29 | "options": {
30 | "filters": {
31 | "fromSources": [
32 | "client",
33 | "formSubmission"
34 | ],
35 | "dataTypes": [
36 | "tableData"
37 | ],
38 | "recordChangeScope": tableID
39 | }
40 | }
41 | }
42 |
43 | const webhooks = await fetch(`https://api.airtable.com/v0/bases/${baseID}/webhooks`, {
44 | headers: {
45 | Authorization: `Bearer ${apiKey}`
46 | }
47 | }).then(res => res.json())
48 |
49 | // Find a webhook that exists with the same specification as `webhookSpecification`
50 | const webhook = webhooks.webhooks?.find(webhook => isEqual(webhook.specification, webhookSpecification))
51 |
52 | if (webhook) {
53 | // Refresh it
54 | // curl -X POST "https://api.airtable.com/v0/bases/{baseID}/webhooks/{webhookId}/refresh" \ -H "Authorization: Bearer YOUR_TOKEN"
55 | const refreshRes = await fetch(`https://api.airtable.com/v0/bases/${baseID}/webhooks/${webhook.id}/refresh`, {
56 | method: 'POST',
57 | headers: {
58 | Authorization: `Bearer ${apiKey}`
59 | }
60 | }).then(res => res.json())
61 |
62 | // Clean up other webhooks
63 | await Promise.all(webhooks.webhooks?.filter(otherWebhook => otherWebhook.id !== webhook.id).map(otherWebhook =>
64 | fetch(`https://api.airtable.com/v0/bases/${baseID}/webhooks/${otherWebhook.id}`, {
65 | method: 'DELETE',
66 | headers: {
67 | Authorization: `Bearer ${apiKey}`
68 | }
69 | })
70 | ))
71 |
72 | // Return status 200 with webhook ID
73 | return res.status(200).json({
74 | refreshRes
75 | })
76 | } else {
77 | // If webhook doesn't exist, create it
78 | const createRes = await fetch(`https://api.airtable.com/v0/bases/${baseID}/webhooks`, {
79 | method: 'POST',
80 | headers: {
81 | Authorization: `Bearer ${apiKey}`,
82 | 'Content-Type': 'application/json'
83 | },
84 | body: JSON.stringify({
85 | "notificationUrl": (new URL('/api/syncToCDN', baseURL)).href,
86 | "specification": webhookSpecification
87 | })
88 | }).then(res => res.json())
89 |
90 | // Return status 200 with webhook ID
91 | return res.status(200).json({
92 | createRes
93 | })
94 | }
95 | }
--------------------------------------------------------------------------------
/data/blogPost.ts:
--------------------------------------------------------------------------------
1 | import { BlogPost, BlogPostAirtableRecord, StaticPage } from './types';
2 | import { airtableBase } from './airtable';
3 | import env from 'env-var';
4 | import { blogPostSchema } from './schema';
5 | import { parseMarkdown } from './markdown';
6 | import { generateCDNMap } from './cloudinary';
7 | import { RecordData } from 'airtable';
8 |
9 | export const formatBlogPost = (blogRecord: BlogPostAirtableRecord): BlogPost | undefined => {
10 | let blog: Omit, 'cdnMap'> & { cdnMap?: BlogPost['cdnMap'] } = { ...blogRecord }
11 | blog.body = parseMarkdown(blogRecord.fields.Body || '')
12 | blog.cdnMap = generateCDNMap(blogRecord)
13 |
14 | // Remove any keys not expected by the parser
15 | const validatedBlog = blogPostSchema.safeParse(blog)
16 | if (validatedBlog.success) {
17 | console.log({ blog, validated: validatedBlog.data })
18 | return validatedBlog.data as BlogPost
19 | } else {
20 | console.error(validatedBlog.error)
21 | }
22 | }
23 |
24 | const blogPostFields = ['Title', 'ByLine', 'Image', 'Body', 'Public', 'Summary', 'Date', 'Slug', 'cdn_urls'] as Array
25 |
26 | export const blogPostBase = () => airtableBase()<
27 | // @ts-ignore
28 | BlogPost['fields']
29 | >(
30 | env.get('AIRTABLE_TABLE_NAME_BLOG_POSTS').default('Blog Posts').asString()
31 | )
32 |
33 | export async function getBlogPosts (): Promise> {
34 | return new Promise((resolve, reject) => {
35 | const blogPosts: BlogPost[] = []
36 |
37 | blogPostBase().select({
38 | sort: [
39 | { field: "Date", direction: "desc", },
40 | ],
41 | filterByFormula: 'AND(Public, Title!="", Summary!="", Date!="", Body!="", Slug!="")',
42 | fields: blogPostFields,
43 | maxRecords: 1000,
44 | view: env.get('AIRTABLE_TABLE_VIEW_BLOG_POSTS').default('Grid view').asString(),
45 | }).eachPage(function page(records, fetchNextPage) {
46 | try {
47 | records.forEach(function(record) {
48 | const blogPost = formatBlogPost(record._rawJson)
49 | if (blogPost) {
50 | blogPosts.push(blogPost)
51 | }
52 | });
53 | fetchNextPage();
54 | } catch (e) {
55 | reject(e)
56 | }
57 | }, function done(err) {
58 | try {
59 | if (err) { reject(err); return; }
60 | resolve(
61 | blogPosts.filter(a => {
62 | const validation = blogPostSchema.safeParse(a)
63 | console.error(a, validation)
64 | return validation.success
65 | })
66 | )
67 | } catch (e) {
68 | reject(e)
69 | }
70 | });
71 | })
72 | }
73 |
74 | export async function getSingleBlogPost (slug: string): Promise {
75 | return new Promise((resolve, reject) => {
76 | blogPostBase().select({
77 | filterByFormula: `AND(Public, Slug="${slug}")`,
78 | fields: blogPostFields,
79 | maxRecords: 1,
80 | view: env.get('AIRTABLE_TABLE_VIEW_BLOG_POSTS').default('Grid view').asString(),
81 | }).firstPage((error, records) => {
82 | try {
83 | if (error || !records?.length) {
84 | return reject(error || `No record found for slug ${slug}`)
85 | }
86 | const blogPost = formatBlogPost(records[0]._rawJson)
87 | if (blogPost) {
88 | return resolve(blogPost)
89 | }
90 | } catch(e) {
91 | reject(e)
92 | }
93 | })
94 | })
95 | }
96 |
97 | export async function updateBlogPosts(updates: RecordData[]) {
98 | return new Promise((resolve, reject) => {
99 | blogPostBase().update(updates, function (err, records) {
100 | if (err) reject(err)
101 | resolve(records)
102 | });
103 | })
104 | }
--------------------------------------------------------------------------------
/data/company.ts:
--------------------------------------------------------------------------------
1 | import { Company } from './types';
2 | import { airtableBase } from './airtable';
3 | import env from 'env-var';
4 | import { companySchema } from './schema';
5 | import { QueryParams } from 'airtable/lib/query_params';
6 | import { getLiveSolidarityActionsByCompanyId } from './solidarityAction';
7 | import { parseMarkdown } from './markdown';
8 |
9 | export const formatCompany = (company: Company) => {
10 | company.fields.Name.trim()
11 |
12 | company.summary = parseMarkdown(company.fields.Summary || '')
13 |
14 | try {
15 | // Remove any keys not expected by the parser
16 | company = companySchema.parse(company)
17 | } catch(e) {
18 | console.error(JSON.stringify(company), e)
19 | }
20 | return company
21 | }
22 |
23 | const fields: Array = ['Name', 'Summary', 'Solidarity Actions']
24 |
25 | export const companyBase = () => airtableBase()(
26 | env.get('AIRTABLE_TABLE_NAME_COMPANIES').default('Companies').asString()
27 | )
28 |
29 | export async function getCompanies (selectArgs: QueryParams = {}): Promise> {
30 | return new Promise((resolve, reject) => {
31 | const companies: Company[] = []
32 |
33 | function finish () {
34 | try {
35 | resolve(
36 | companies.filter(a =>
37 | companySchema.safeParse(a).success === true
38 | )
39 | )
40 | } catch (e) {
41 | reject(e)
42 | }
43 | }
44 |
45 | companyBase().select({
46 | sort: [
47 | { field: "Name", direction: "asc", },
48 | ],
49 | fields: fields,
50 | maxRecords: 1000,
51 | // view: env.get('AIRTABLE_TABLE_VIEW_COMPANIES').default('Grid view').asString(),
52 | filterByFormula: 'COUNTA({Solidarity Actions}) > 0',
53 | ...selectArgs
54 | }).eachPage(function page(records, fetchNextPage) {
55 | try {
56 | records.forEach(function(record) {
57 | companies.push(formatCompany(record._rawJson))
58 | });
59 | fetchNextPage();
60 | } catch(e) {
61 | finish()
62 | }
63 | }, function done(err) {
64 | if (err) { reject(err); return; }
65 | finish()
66 | });
67 | })
68 | }
69 |
70 | export async function getCompanyBy (selectArgs: QueryParams = {}, description?: string) {
71 | return new Promise((resolve, reject) => {
72 | companyBase().select({
73 | // sort: [
74 | // { field: "Name", direction: "asc", },
75 | // ],
76 | fields: fields,
77 | maxRecords: 1,
78 | // view: env.get('AIRTABLE_TABLE_VIEW_COMPANIES').default('Grid view').asString(),
79 | ...selectArgs
80 | }).firstPage(function page(error, records) {
81 | try {
82 | if (error) console.error(error)
83 | if (error || !records?.length) {
84 | return reject(`No companies was found for filter ${JSON.stringify(selectArgs)}`)
85 | }
86 | const company = records?.[0]._rawJson
87 | resolve(formatCompany(company))
88 | } catch(e) {
89 | reject(e)
90 | }
91 | })
92 | })
93 | }
94 |
95 | export async function getCompanyByName (name: string) {
96 | return getCompanyBy({
97 | filterByFormula: `{Name}="${name}"`
98 | })
99 | }
100 |
101 | export type CompanyData = {
102 | company: Company
103 | }
104 |
105 | export const getCompanyDataByCode = async (name: string): Promise => {
106 | const company = await getCompanyByName(name)
107 | if (!company) {
108 | throw new Error("No such company was found for this company code.")
109 | }
110 |
111 | const solidarityActions = await getLiveSolidarityActionsByCompanyId(company.id)
112 |
113 | return {
114 | company: {
115 | ...company,
116 | solidarityActions
117 | }
118 | }
119 | }
--------------------------------------------------------------------------------
/data/category.ts:
--------------------------------------------------------------------------------
1 | import { Category } from './types';
2 | import { airtableBase } from './airtable';
3 | import env from 'env-var';
4 | import { categorySchema } from './schema';
5 | import { QueryParams } from 'airtable/lib/query_params';
6 | import { getLiveSolidarityActionsByCategoryId } from './solidarityAction';
7 | import { parseMarkdown } from './markdown';
8 |
9 | export const formatCategory = (category: Category) => {
10 | category.fields.Name.trim()
11 |
12 | category.summary = parseMarkdown(category.fields.Summary || '')
13 |
14 | try {
15 | // Remove any keys not expected by the parser
16 | category = categorySchema.parse(category)
17 | } catch(e) {
18 | console.error(JSON.stringify(category), e)
19 | }
20 | return category
21 | }
22 |
23 | const fields: Array = ['Name', 'Summary', 'Emoji', 'Solidarity Actions']
24 |
25 | export const categoryBase = () => airtableBase()(
26 | env.get('AIRTABLE_TABLE_NAME_CATEGORIES').default('Categories').asString()
27 | )
28 |
29 | export async function getCategories (selectArgs: QueryParams = {}): Promise> {
30 | return new Promise((resolve, reject) => {
31 | const categories: Category[] = []
32 |
33 | function finish () {
34 | try {
35 | resolve(
36 | categories.filter(a =>
37 | categorySchema.safeParse(a).success === true
38 | )
39 | )
40 | } catch (e) {
41 | reject(e)
42 | }
43 | }
44 |
45 | categoryBase().select({
46 | sort: [
47 | { field: "Name", direction: "asc", },
48 | ],
49 | fields: fields,
50 | maxRecords: 1000,
51 | // view: env.get('AIRTABLE_TABLE_VIEW_CATEGORIES').default('Grid view').asString(),
52 | filterByFormula: 'COUNTA({Solidarity Actions}) > 0',
53 | ...selectArgs
54 | }).eachPage(function page(records, fetchNextPage) {
55 | try {
56 | records.forEach(function(record) {
57 | categories.push(formatCategory(record._rawJson))
58 | });
59 | fetchNextPage();
60 | } catch(e) {
61 | finish()
62 | }
63 | }, function done(err) {
64 | if (err) { reject(err); return; }
65 | finish()
66 | });
67 | })
68 | }
69 |
70 | export async function getCategoryBy (selectArgs: QueryParams = {}, description?: string) {
71 | return new Promise((resolve, reject) => {
72 | categoryBase().select({
73 | // sort: [
74 | // { field: "Name", direction: "asc", },
75 | // ],
76 | fields: fields,
77 | maxRecords: 1,
78 | // view: env.get('AIRTABLE_TABLE_VIEW_CATEGORIES').default('Grid view').asString(),
79 | ...selectArgs
80 | }).firstPage(function page(error, records) {
81 | try {
82 | if (error) console.error(error)
83 | if (error || !records?.length) {
84 | return reject(`No categories was found for filter ${JSON.stringify(selectArgs)}`)
85 | }
86 | const category = records?.[0]._rawJson
87 | resolve(formatCategory(category))
88 | } catch(e) {
89 | reject(e)
90 | }
91 | })
92 | })
93 | }
94 |
95 | export async function getCategoryByName (name: string) {
96 | return getCategoryBy({
97 | filterByFormula: `{Name}="${name}"`
98 | })
99 | }
100 |
101 | export type CategoryData = {
102 | category: Category
103 | }
104 |
105 | export const getCategoryDataByCode = async (name: string): Promise => {
106 | const category = await getCategoryByName(name)
107 | if (!category) {
108 | throw new Error("No such category was found for this category code.")
109 | }
110 |
111 | const solidarityActions = await getLiveSolidarityActionsByCategoryId(category.id)
112 |
113 | return {
114 | category: {
115 | ...category,
116 | solidarityActions
117 | }
118 | }
119 | }
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @typedef { import('tailwindcss/defaultConfig') } DefaultConfig */
2 | /** @typedef { import('tailwindcss/defaultTheme') } DefaultTheme */
3 | /** @typedef { DefaultConfig & { theme: { extend: DefaultTheme } } } TailwindConfig */
4 |
5 | const defaultTheme = require('tailwindcss/defaultTheme')
6 | const polished = require('polished')
7 |
8 | /** @type {TailwindConfig} */
9 | module.exports = {
10 | mode: "jit",
11 | purge: [
12 | './pages/**/*.{js,ts,jsx,tsx}',
13 | './components/**/*.{js,ts,jsx,tsx}'
14 | ],
15 | darkMode: false, // or 'media' or 'class'
16 | theme: {
17 | screens: {
18 | sm: '640px',
19 | md: '768px',
20 | lg: '1024px',
21 | xl: '1280px',
22 | '2xl': '1536px',
23 | },
24 | spacing: {
25 | 0: '0px',
26 | 1: '5px',
27 | 2: '10px',
28 | 3: '15px',
29 | 4: '20px',
30 | 5: '40px',
31 | 6: '60px',
32 | 7: '80px',
33 | 8: '100px',
34 | },
35 | space: {
36 | 0: '0px',
37 | '1px': "1px",
38 | '2px': "2px",
39 | '3px': "3px",
40 | '4px': "4px",
41 | 1: '5px',
42 | 2: '10px',
43 | 3: '15px',
44 | 4: '20px',
45 | 5: '40px',
46 | 6: '60px',
47 | 7: '80px',
48 | 8: '100px',
49 | },
50 | extend: {
51 | fontFamily: {
52 | identity: [
53 | 'Parabole',
54 | ...defaultTheme.fontFamily.sans
55 | ]
56 | },
57 | colors: {
58 | transparent: 'transparent',
59 | inherit: 'inherit',
60 | gwYellow: '#EBFF00',
61 | gwBlue: '#3B97EC',
62 | gwBlueLight: '#E8EFF5',
63 | gwBackground: '#F8F8F8',
64 | gwPink: '#DD96FF',
65 | gwPink50: polished.rgba('#DD96FF', 0.5),
66 | gwPinkLight: '#FFCAD9' ,
67 | gwOrange: '#FF8038',
68 | gwOrange50: polished.rgba('#FF8038', 0.5),
69 | gwOrangeLight: '#FFC58E',
70 | },
71 | boxShadow: theme => ({
72 | 'noglow': 'inset 0 0 0 0 #FF8038',
73 | 'glow': 'inset 4px 4px 20px 6px #FF8038',
74 | 'innerGwPink': `inset 0px 0px 10px 6px ${polished.rgba('#DD96FF', 0.5)}`,
75 | 'gwPink': `0px 1px 10px 5px ${theme('colors.gwPink50')}`,
76 | 'gwOrange': `0px 1px 10px 5px ${theme('colors.gwOrange50')}`
77 | }),
78 | maxWidth: {
79 | full: "100%"
80 | },
81 | typography: theme => ({
82 | DEFAULT: {
83 | css: {
84 | color: 'inherit',
85 | h1: { color: 'inherit' },
86 | h2: { color: 'inherit' },
87 | h3: { color: 'inherit' },
88 | h4: { color: 'inherit' },
89 | h5: { color: 'inherit' },
90 | a: {
91 | color: '#3182ce',
92 | '&:hover': {
93 | color: '#2c5282',
94 | },
95 | },
96 | p: {
97 | marginTop: theme('space.4'),
98 | marginBottom: theme('space.4')
99 | },
100 | blockquote: {
101 | marginTop: theme('space.5'),
102 | marginBottom: theme('space.5'),
103 | fontFamily: theme('fontFamily.identity').slice().reverse(),
104 | fontSize: theme('fontSize.3xl'),
105 | lineHeight: '1.25em',
106 | fontStyle: 'normal',
107 | border: 'none',
108 | margin: 'none',
109 | // background: `0% 0% url(/images/spaceinvader.png) no-repeat`,
110 | // backgroundSize: '32px 38px',
111 | paddingLeft: 40,
112 | ' p:first-of-type::before': {
113 | content: '"👾" !important',
114 | float: 'left',
115 | marginLeft: -40
116 | },
117 | ':after': { display: 'none' },
118 | }
119 | },
120 | },
121 | })
122 | },
123 | },
124 | variants: {
125 | extend: {},
126 | },
127 | plugins: [
128 | require('@tailwindcss/typography'),
129 | ],
130 | }
131 |
--------------------------------------------------------------------------------
/components/ActionChart.tsx:
--------------------------------------------------------------------------------
1 | import { ParentSize } from '@visx/responsive';
2 | import {
3 | Axis,
4 | BarSeries, ThemeContext, XYChart
5 | } from '@visx/xychart';
6 | import { bin, HistogramGeneratorNumber } from "d3-array";
7 | import { timeMonth, timeMonths, timeYears } from 'd3-time';
8 | import { timeFormat } from 'd3-time-format';
9 | import { min } from 'date-fns';
10 | import { useMemo } from 'react';
11 | import { theme } from 'twin.macro';
12 | import { SolidarityAction } from '../data/types';
13 | import { useMediaQuery } from '../utils/mediaQuery';
14 | import { up } from '../utils/screens';
15 |
16 | export function CumulativeMovementChart ({ data, onSelectYear }: { data: SolidarityAction[], cumulative?: boolean, onSelectYear?: (year: string) => void }) {
17 | const actionDates = data.map(d => new Date(d.fields.Date))
18 | const minDate = min([new Date('2000-01-01'), ...actionDates])
19 | const maxDate = new Date()
20 |
21 | return (
22 |
23 |
{(parent) => (
24 | <>
25 |
33 | >
34 | )}
35 |
36 | )
37 | }
38 |
39 | type Data = ReturnType>
40 | type Datum = Data[0] & { y: number }
41 |
42 | type AccessorFn = (d: Datum) => any
43 |
44 | const accessors: {
45 | xAccessor: AccessorFn
46 | yAccessor: AccessorFn
47 | } = {
48 | xAccessor: bin => Number(bin['x0']),
49 | yAccessor: bin => bin.y,
50 | };
51 |
52 | export function CumulativeChart ({
53 | data,
54 | height = 300,
55 | width = 300,
56 | minDate,
57 | maxDate,
58 | onSelectYear
59 | }: {
60 | minDate: Date
61 | maxDate: Date
62 | data: SolidarityAction[]
63 | height: number,
64 | width: number,
65 | cumulative?: boolean
66 | onSelectYear?: (year: string) => void
67 | }) {
68 | var yearBins = timeYears(timeMonth.offset(minDate, -1), timeMonth.offset(maxDate, 1));
69 |
70 | const createBinFn = (dateBins: Date[]) => {
71 | return bin()
72 | .thresholds(dateBins)
73 | .value(d => new Date(d.fields.Date))
74 | .domain([minDate, maxDate])
75 | }
76 |
77 | const yearBinFn = createBinFn(yearBins)
78 |
79 | const binnedData = useMemo(() => {
80 | let d = yearBinFn(data)
81 | for(var i = 0; i < d.length; i++) {
82 | d[i]['y'] = d[i].length || 0
83 | }
84 | return d
85 | }, [data])
86 |
87 | const isSmallScreen = !useMediaQuery(up('xl'))
88 |
89 | return (
90 |
112 |
119 | {
123 | onSelectYear?.(timeFormat('%Y')(accessors.xAccessor(e.datum)))
124 | }}
125 | />
126 |
130 |
131 |
132 | )
133 | }
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # [gameworkersolidarity.com](https://gameworkersolidarity.com)
2 |
3 | Game Worker Solidarity is mapping and documenting collective movements by game workers striving to improve their working conditions. We're collecting materials created by workers for these movements and aim to document the longer history of resistance in the industry which goes back to its formation.
4 |
5 | This repository is for a website backed by a database of events that can be freely searched by location, type of action, and numbers involved for events like the creation of trade union branches, new contracts, strikes, protests, social media campaigns, etc.
6 |
7 | Where possible, we'll also interview and record oral histories with participants of these movements to produce a living resource that can help support and inspire more organising in the games industry.
8 |
9 | Do you have any information to share with us that we can add to the timeline? [Get in touch!](mailto:hello@gameworkersolidarity.com)
10 |
11 | # Technical documentation
12 |
13 | Check out this [early stage, highly WIP documentation for the overall system.](https://www.notion.so/commonknowledge/System-Documentation-9986bf296f5341d0b0f0c1f66b67cd24) Later we will transpose that content to this README file.
14 |
15 | ## Getting started: run it on your machine
16 |
17 | First, download the code from github (e.g. `git clone`).
18 |
19 | You will need to copy `.env.template` to `.env.local` and fill out the required env variables.
20 |
21 | - The Airtable private API key can be found [here, in your account settings.](https://airtable.com/account)
22 |
23 | To run the system locally, on your machine you will need:
24 |
25 | - `node` (recommend installing and managing this via [`nvm`](https://github.com/nvm-sh/nvm#installing-and-updating))
26 | - ideally also [`yarn`](https://yarnpkg.com/getting-started/install), because we pin specific versions of package dependencies using yarn (see [`./yarn.lock`]('./yarn.lock'))
27 |
28 | Install the required package dependencies:
29 |
30 | ```bash
31 | yarn
32 | # or
33 | npm install
34 | ```
35 |
36 | Then you can run the development server:
37 |
38 | ```bash
39 | yarn dev
40 | # or
41 | npm run dev
42 | ```
43 |
44 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
45 |
46 | ## Development guide
47 |
48 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). To learn more about Next.js, take a look at the following resources:
49 |
50 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
51 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
52 |
53 | ### Pages
54 |
55 | You can start editing the page by modifying `pages/index.js`. The page auto-updates as you edit the file.
56 | ### API routes
57 |
58 | [API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.js`.
59 |
60 | The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages.
61 |
62 | ### Typescript interfaces and parsers
63 |
64 | In development we've been generating schemas from typescript interfaces, to help smooth out API responses. When things aren't as expected, they don't show up.
65 |
66 | To add to the schema, add interfaces to `types.ts`.
67 |
68 | To regenerate the schema (at `schema.ts`):
69 |
70 | ```bash
71 | yarn generateschema
72 | ```
73 |
74 | ## Deployment
75 |
76 | This repo auto-deploys to Digital Ocean.
77 |
78 | ## CDN for public file hosting
79 |
80 | Cloudinary is used as a public CDN for Airtable images. Here's how it works:
81 |
82 | - The `/api/syncToCDN` endpoint is responsible for refreshing the `cdn_urls` to sync Airtable's private attachments to the public CDN and then store the public URLs back in Airtable for serving in the frontend.
83 | - The hidden `cdn_urls` column which stores data about the publicly viewable URLs should not be edited manually.
84 | - Whenever an Airtable record is updated, a webhook will trigger the re-sync. A [Github action](./.github/workflows/refreshWebhook.yml) regularly triggers [maintenance script](./pages/api/createOrRefreshAirtableWebhook.ts), which will create/refresh the managed webhook to the Airtable.
85 | - The webhook management script requires an access token in the env (`AIRTABLE_API_KEY`) configured [via this URL](https://airtable.com/create/tokens/new) as follows:
86 | 
87 |
--------------------------------------------------------------------------------
/pages/about.tsx:
--------------------------------------------------------------------------------
1 | import { getSingleStaticPage } from '../data/staticPage';
2 | import { StaticPage } from '../data/types';
3 | import { NextSeo } from 'next-seo';
4 | import env from 'env-var';
5 | import { GetStaticProps } from 'next';
6 | import PageLayout from '../components/PageLayout';
7 | import ErrorPage from './404'
8 | import { projectStrings } from '../data/site';
9 | import { OpenUniversityLogo } from '../components/OpenUniversityLogo';
10 |
11 | type PageProps = { article: StaticPage | null, errorMessage?: string }
12 | type PageParams = { slug: string[] }
13 |
14 | export default function Page({ article, errorMessage }: PageProps) {
15 | if (!article) return
16 |
17 | return (
18 |
19 |
27 |
28 |
29 |
30 | {article.fields.Title}
31 |
32 |
35 |
36 |
37 |
44 |
45 | Additional Help From
46 | Pablo Lopez Soriano, Game worker and union organizer, IWGB. @kednar
47 | Michelle Phan, Research Assistant, University of Toronto. @phanny
48 |
49 |
64 |
65 |
66 | Credits
67 | This website was developed as part of the Mapping labour organising in games industry: past, present, and future project, funded by PVC-RES at The Open University
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 | )
76 | }
77 |
78 | export const getStaticProps: GetStaticProps<
79 | PageProps, PageParams
80 | > = async () => {
81 | let article
82 | let errorMessage = ''
83 | try {
84 | article = await getSingleStaticPage('about') || null
85 | } catch (e) {
86 | console.error("No article was found", e)
87 | article = null
88 | errorMessage = e.toString()
89 | }
90 |
91 | return {
92 | props: {
93 | article,
94 | errorMessage
95 | },
96 | revalidate: env.get('PAGE_TTL').default(
97 | env.get('NODE_ENV').asString() === 'production' ? 60 : 5
98 | ).asInt(), // In seconds
99 | }
100 | }
--------------------------------------------------------------------------------
/pages/analysis/[slug].tsx:
--------------------------------------------------------------------------------
1 | import { getBlogPosts } from '../../data/blogPost';
2 | import { BlogPost } from '../../data/types';
3 | import { NextSeo } from 'next-seo';
4 | import env from 'env-var';
5 | import { GetStaticPaths, GetStaticProps } from 'next';
6 | import PageLayout from '../../components/PageLayout';
7 | import ErrorPage from '../404'
8 | import Image from 'next/image';
9 | import { DateTime } from '../../components/Date';
10 | import { BlogPostThumbnail } from '../../components/BlogPost';
11 |
12 | type PageProps = { article: BlogPost | null, moreArticles: BlogPost[], errorMessage?: string }
13 | type PageParams = { slug: string }
14 |
15 | export default function Page({ moreArticles, article, errorMessage }: PageProps) {
16 | if (!article) return
17 |
18 | const image = article.cdnMap?.[0];
19 |
20 | return (
21 |
22 | image.filetype.includes("image/")).map(image => ({
29 | url: image.originalURL
30 | }))
31 | }) : ({
32 | title: article.fields.Title,
33 | description: article.fields.Summary,
34 | })}
35 | />
36 |
37 |
38 |
67 |
70 |
71 |
72 | {moreArticles.length != 0 ?
73 |
74 | Read more
75 |
76 | {moreArticles.slice(0, 3).map(b =>
77 |
78 | )}
79 |
80 |
81 | : ""}
82 |
83 | )
84 | }
85 |
86 | export const getStaticPaths: GetStaticPaths = async (context) => {
87 | const links = (await getBlogPosts()).filter(page => typeof page.fields.Slug === 'string')
88 | return {
89 | paths: links.map(page => ({
90 | params: {
91 | slug: page.fields.Slug!
92 | }
93 | })),
94 | fallback: true
95 | }
96 | }
97 |
98 | export const getStaticProps: GetStaticProps<
99 | PageProps, PageParams
100 | > = async (context) => {
101 | if (!context?.params?.slug) throw new Error()
102 |
103 | let article
104 | let moreArticles
105 | let errorMessage = ''
106 | try {
107 | moreArticles = await getBlogPosts()
108 | article = moreArticles.find(a => a.fields.Slug === context.params!.slug)
109 | } catch (e) {
110 | console.error("No article was found", e)
111 | article = null
112 | errorMessage = e.toString()
113 | }
114 |
115 | return {
116 | props: {
117 | article,
118 | moreArticles: moreArticles.filter(a => a.id !== article.id),
119 | errorMessage
120 | },
121 | revalidate: env.get('PAGE_TTL').default(
122 | env.get('NODE_ENV').asString() === 'production' ? 60 : 5
123 | ).asInt(), // In seconds
124 | }
125 | }
--------------------------------------------------------------------------------
/utils/state.ts:
--------------------------------------------------------------------------------
1 | import { useRef, useEffect, Dispatch, SetStateAction, useState } from 'react';
2 | import qs from 'query-string';
3 | import { useRouter } from 'next/dist/client/router';
4 | import { useDebouncedCallback } from 'use-debounce'
5 | import isEqual from 'lodash.isequal'
6 | import { diff as jsondiff, jsonPatchPathConverter, Operation } from 'just-diff';
7 | import jsonpatch, { JSONPatch } from 'jsonpatch';
8 |
9 | type URLStateOptions = {
10 | key: string
11 | initialValue: RawValue
12 | emptyValue: RawValue
13 | serialiseObjectToState: (key: string, value: string | string[] | null) => RawValue
14 | serialiseStateToObject: (key: string, state: RawValue) => object
15 | }
16 |
17 | /**
18 | * Subscribe to some URL keys
19 | */
20 | export function useURLStateFactory () {
21 | const router = useRouter()
22 | const params = useRef({})
23 | const paramDiffs = useRef>([])
24 |
25 | const updateURL = useDebouncedCallback(() => {
26 | const { query } = qs.parseUrl(router.asPath)
27 | const nextQuery = { ...query, ...jsonpatch.apply_patch(params.current, paramDiffs.current) }
28 | for (const key in nextQuery) {
29 | if (!nextQuery[key]) {
30 | delete nextQuery[key]
31 | }
32 | }
33 | // console.log(query, nextQuery, paramDiffs.current)
34 | paramDiffs.current = []
35 | if (isEqual(query, nextQuery)) {
36 | // console.info("Update URL called, but URL was the same as before", { query, nextQuery })
37 | } else {
38 | router.push({ query: nextQuery }, undefined, { shallow: true, scroll: false })
39 | }
40 | }, 500)
41 |
42 | return function <
43 | RawValue = any
44 | >(
45 | options: Pick, 'key' | 'emptyValue'> & Partial>
46 | ) {
47 | const {
48 | serialiseObjectToState,
49 | serialiseStateToObject,
50 | initialValue,
51 | emptyValue,
52 | key
53 | } = Object.assign(
54 | {
55 | serialiseObjectToState: (key, nextState) => nextState as any,
56 | serialiseStateToObject: (key: string, state: any) => {
57 | const url = qs.parseUrl(router.asPath)
58 | const currentURLValue = url.query[key]
59 | // console.info("Syncing new state to URL", key, currentURLValue, state)
60 | if (isEqual(state, emptyValue)) {
61 | return ({ [key]: undefined })
62 | }
63 | return ({ [key]: state })
64 | }
65 | } as URLStateOptions,
66 | options
67 | )
68 |
69 | // Look for initial value from `key`
70 | const initialUrlValue = qs.parseUrl(router.asPath).query[key]
71 | const initialStateValue = serialiseObjectToState(key, initialUrlValue)
72 |
73 | // Initialise state
74 | const [state, setState] = useState(initialStateValue || initialValue || emptyValue)
75 |
76 | // When the URL changes, sync it to state
77 | useEffect(function deserialiseURLToState() {
78 | const handleRouteChange = (url, { shallow }) => {
79 | if (shallow) return
80 | const params = qs.parseUrl(url)
81 | const nextState = serialiseObjectToState(key, params.query[key])
82 | if (isEqual(state, nextState)) return
83 | // console.info(`Syncing new URL params to state`, key, state, nextState)
84 | setState(nextState)
85 | }
86 | router.events.on('routeChangeComplete', handleRouteChange)
87 | return () => {
88 | router.events.off('routeChangeComplete', handleRouteChange)
89 | }
90 | }, [state])
91 |
92 | // When state changes, update the params which will eventually be synced to the URL
93 | useEffect(function serialiseStateToURL() {
94 | const nextParams = ({ ...params.current, ...serialiseStateToObject(key, state) })
95 | const diff = jsondiff(params.current, nextParams, jsonPatchPathConverter)
96 | paramDiffs.current = paramDiffs.current.concat(diff)
97 | // console.log('State changed, new params calculated', params.current, nextParams)
98 | for (const key in params.current) {
99 | if (!params.current[key]) {
100 | delete params.current[key]
101 | }
102 | }
103 | updateURL()
104 | }, [state /* state value */])
105 |
106 | // Pass through state
107 | return [state, setState, options] as const
108 | }
109 | }
110 |
111 | export function usePrevious(value: T) {
112 | // The ref object is a generic container whose current property is mutable ...
113 | // ... and can hold any value, similar to an instance property on a class
114 | const ref = useRef();
115 |
116 | // Store current value in ref
117 | useEffect(() => {
118 | ref.current = value;
119 | }, [value]); // Only re-run if value changes
120 |
121 | // Return previous value (happens before update in useEffect above)
122 | return ref.current;
123 | }
--------------------------------------------------------------------------------
/components/PageLayout.tsx:
--------------------------------------------------------------------------------
1 | import Link from 'next/link';
2 | import cx from 'classnames';
3 | import useScrollPosition from '@react-hook/window-scroll'
4 | import { useRef } from 'react';
5 |
6 | type Links = Array<{ url: string, label: string }>
7 |
8 | const headerLinks: Links = [
9 | // All header links as JSON objects
10 | { url: '/analysis', label: 'Analysis' },
11 | { url: '/start-organising', label: 'Organise!' },
12 | { url: '/submit', label: 'Submit Action' },
13 | { url: '/data', label: 'Data' },
14 | { url: '/about', label: 'About' }
15 | ]
16 |
17 | const footerLinks: Links = [
18 | // All footer links as JSON objects
19 | { url: '/analysis', label: 'Analysis' },
20 | { url: '/start-organising', label: 'Organise!' },
21 | { url: '/submit', label: 'Submit Action' },
22 | { url: '/data', label: 'Data' },
23 | { url: '/about', label: 'About' },
24 | { url: 'https://github.com/gameworkersolidarity/website', label: 'GitHub' },
25 | { url: 'https://bsky.app/profile/gameworkersolidarity.com', label: 'Bluesky' },
26 | { url: 'https://twitter.com/GWSolidarity', label: 'Twitter' },
27 | { url: 'mailto:hello@gameworkersolidarity.com', label: 'Email' }
28 | ]
29 |
30 | export default function PageLayout({ children }: { children: any }) {
31 | return (
32 |
33 |
34 |
35 |
36 | {children}
37 |
38 |
39 |
40 |
41 | )
42 | }
43 |
44 | function Header({ }: {}) {
45 | const headerRef = useRef(null)
46 | const scrollY = useScrollPosition(60 /*fps*/)
47 | const isFloating = scrollY > ((headerRef.current?.clientHeight || 100) * 0.75)
48 |
49 | return (
50 | <>
51 |
68 |
89 |
90 | >
91 | )
92 | }
93 |
94 | function Footer() {
95 | return (
96 |
124 | )
125 | }
--------------------------------------------------------------------------------
/data/organisingGroup.ts:
--------------------------------------------------------------------------------
1 | import { OrganisingGroup } from './types';
2 | import { airtableBase } from './airtable';
3 | import env from 'env-var';
4 | import { organisingGroupSchema } from './schema';
5 | import { QueryParams } from 'airtable/lib/query_params';
6 | import { getLiveSolidarityActionsByOrganisingGroupId } from './solidarityAction';
7 | import { countryDataForCode } from './country';
8 |
9 | export const formatOrganisingGroup = (organisingGroup: OrganisingGroup) => {
10 | organisingGroup.fields.Name.trim()
11 | organisingGroup.geography = { country: [] }
12 | organisingGroup.slug = organisingGroup.fields.slug || organisingGroup.id
13 |
14 | let i = 0
15 | for (const countryCode of (organisingGroup.fields.countryCode || [])) {
16 | try {
17 | organisingGroup.geography.country.push(countryDataForCode(countryCode))
18 | } catch (e) {
19 | console.error(JSON.stringify(organisingGroup), e)
20 | }
21 | i++;
22 | }
23 |
24 | try {
25 | // Remove any keys not expected by the parser
26 | organisingGroup = organisingGroupSchema.parse(organisingGroup)
27 | } catch(e) {
28 | console.error(JSON.stringify(organisingGroup), e)
29 | }
30 | return organisingGroup
31 | }
32 |
33 | const fields: Array = ['LastModified', 'slug', 'Name', 'Full Name', 'Country', 'countryCode', 'countryName', 'Solidarity Actions', 'IsUnion', 'Website', 'Twitter', 'Bluesky']
34 |
35 | export const organisingGroupBase = () => airtableBase()(
36 | env.get('AIRTABLE_TABLE_NAME_GROUPS').default('Organising Groups').asString()
37 | )
38 |
39 | export async function getOrganisingGroups (selectArgs: QueryParams = {}): Promise> {
40 | return new Promise((resolve, reject) => {
41 | const groups: OrganisingGroup[] = []
42 |
43 | function finish () {
44 | try {
45 | resolve(
46 | groups.filter(a =>
47 | organisingGroupSchema.safeParse(a).success === true
48 | )
49 | )
50 | } catch (e) {
51 | reject(e)
52 | }
53 | }
54 |
55 | organisingGroupBase().select({
56 | sort: [
57 | { field: "Name", direction: "asc", }
58 | ],
59 | fields: fields,
60 | maxRecords: 1000,
61 | // filterByFormula: 'COUNTA({Solidarity Actions}) > 0',
62 | view: env.get('AIRTABLE_TABLE_VIEW_ORGANISING_GROUPS').default('All Groups').asString(),
63 | ...selectArgs
64 | }).eachPage(function page(records, fetchNextPage) {
65 | try {
66 | records.forEach(function(record) {
67 | groups.push(formatOrganisingGroup(record._rawJson))
68 | });
69 | fetchNextPage();
70 | } catch(e) {
71 | finish()
72 | }
73 | }, function done(err) {
74 | if (err) { reject(err); return; }
75 | finish()
76 | });
77 | })
78 | }
79 |
80 | export async function getOrganisingGroupBy (selectArgs: QueryParams = {}, description?: string) {
81 | return new Promise((resolve, reject) => {
82 | organisingGroupBase().select({
83 | fields: fields,
84 | maxRecords: 1,
85 | view: env.get('AIRTABLE_TABLE_VIEW_ORGANISING_GROUPS').default('All Groups').asString(),
86 | ...selectArgs
87 | }).firstPage(function page(error, records) {
88 | try {
89 | if (error) console.error(error)
90 | if (error || !records?.length) {
91 | return reject(`No group was found for filter ${JSON.stringify(selectArgs)}`)
92 | }
93 | const organisingGroup = records?.[0]._rawJson
94 | resolve(formatOrganisingGroup(organisingGroup))
95 | } catch(e) {
96 | reject(e)
97 | }
98 | })
99 | })
100 | }
101 |
102 | export async function getOrganisingGroupsByCountryCode (iso2: string) {
103 | const filterByFormula = `FIND("${iso2}", ARRAYJOIN({countryCode})) > 0`
104 | return getOrganisingGroups({ filterByFormula })
105 | }
106 |
107 | export async function getOrganisingGroupsByCountryId (id: string) {
108 | const filterByFormula = `FIND("${id}", ARRAYJOIN({Country})) > 0`
109 | return getOrganisingGroups({ filterByFormula })
110 | }
111 |
112 | export async function getOrganisingGroupByName (name: string) {
113 | return getOrganisingGroupBy({
114 | filterByFormula: `{Name}="${name}"`
115 | })
116 | }
117 |
118 | export type OrganisingGroupData = {
119 | organisingGroup: OrganisingGroup
120 | }
121 |
122 | export const getOrganisingGroupDataByName = async (name: string): Promise => {
123 | const organisingGroup = await getOrganisingGroupByName(name)
124 | if (!organisingGroup) {
125 | throw new Error("No such organising group was found for this code.")
126 | }
127 |
128 | const solidarityActions = await getLiveSolidarityActionsByOrganisingGroupId(organisingGroup.id)
129 |
130 | return {
131 | organisingGroup: {
132 | ...organisingGroup,
133 | solidarityActions
134 | }
135 | }
136 | }
137 |
138 | export async function getSingleOrganisingGroup (id: string) {
139 | const filterByFormula = `OR({slug}="${id}", RECORD_ID()="${id}")`
140 | return getOrganisingGroupBy({ filterByFormula })
141 | }
142 |
143 | export function groupUrl(group: OrganisingGroup): string {
144 | return `/group/${group.slug}`
145 | }
--------------------------------------------------------------------------------
/components/GameLogo.tsx:
--------------------------------------------------------------------------------
1 | export function GameLogo () {
2 | return (
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 | )
13 | }
--------------------------------------------------------------------------------
/data/country.ts:
--------------------------------------------------------------------------------
1 | import { Country, SolidarityAction, CountryEmoji, Geography } from './types';
2 | import { airtableBase } from './airtable';
3 | import env from 'env-var';
4 | import { countrySchema, solidarityActionSchema } from './schema';
5 | import { QueryParams } from 'airtable/lib/query_params';
6 | import { getLiveSolidarityActionsByCountryCode } from './solidarityAction';
7 | import { parseMarkdown } from './markdown';
8 | import { getOrganisingGroupsByCountryId } from './organisingGroup';
9 | import coords from 'country-coords'
10 | import countryFlagEmoji from 'country-flag-emoji';
11 | const coordsByCountry = coords.byCountry()
12 | import { toBBOX } from 'country-to-bbox'
13 |
14 | export function countryDataForCode (countryCode: string): Geography['country'][0] {
15 | // Add country data
16 | const { country: iso3166, ...countryCoordData } = coordsByCountry.get(countryCode)
17 | const emoji = countryFlagEmoji.get(countryCode) as CountryEmoji
18 | const bbox = emoji.name === 'France' ? [-5.4534286, 41.2632185, 9.8678344, 51.268318] : (emoji.name === 'Malta' ? [14.1803710938, 35.8202148437, 14.5662109375, 36.07578125] : toBBOX(emoji.name === 'South Korea' ? 'S. Korea' : emoji.name))
19 | return {
20 | name: emoji.name,
21 | emoji,
22 | iso3166,
23 | bbox,
24 | ...countryCoordData
25 | }
26 | }
27 |
28 | export const formatCountry = (country: Country) => {
29 | country.emoji = countryFlagEmoji.get(country.fields.countryCode) as CountryEmoji
30 | country.fields.Name.trim()
31 |
32 | country.summary = parseMarkdown(country.fields.Summary || '')
33 |
34 | try {
35 | // Remove any keys not expected by the parser
36 | country = countrySchema.parse(country)
37 | } catch(e) {
38 | console.error(JSON.stringify(country), e)
39 | }
40 | return country
41 | }
42 |
43 | const fields: Array = ['Name', 'countryCode', 'Summary', 'Slug', 'Solidarity Actions']
44 |
45 | export const countryBase = () => airtableBase()(
46 | env.get('AIRTABLE_TABLE_NAME_COUNTRIES').default('Countries').asString()
47 | )
48 |
49 | export async function getCountries (selectArgs: QueryParams = {}): Promise> {
50 | return new Promise((resolve, reject) => {
51 | const countries: Country[] = []
52 |
53 | function finish () {
54 | try {
55 | resolve(
56 | countries.filter(a =>
57 | countrySchema.safeParse(a).success === true
58 | )
59 | )
60 | } catch (e) {
61 | console.log("CountryError")
62 | reject(e)
63 | }
64 | }
65 |
66 | countryBase().select({
67 | sort: [
68 | { field: "Name", direction: "asc", },
69 | ],
70 | fields: fields,
71 | maxRecords: 249,
72 | view: env.get('AIRTABLE_TABLE_VIEW_COUNTRIES').default('Grid view').asString(),
73 | filterByFormula: 'COUNTA({Solidarity Actions}) > 0',
74 | ...selectArgs
75 | }).eachPage(function page(records, fetchNextPage) {
76 | try {
77 | records.forEach(function(record) {
78 | countries.push(formatCountry(record._rawJson))
79 | });
80 | fetchNextPage();
81 | } catch(e) {
82 | console.log("Countryerror2")
83 | finish()
84 | }
85 | }, function done(err) {
86 | if (err) { reject(err); return; }
87 | finish()
88 | });
89 | })
90 | }
91 |
92 | export async function getCountryBy (selectArgs: QueryParams = {}, description?: string) {
93 | let country: Country
94 | return new Promise((resolve, reject) => {
95 | countryBase().select({
96 | // sort: [
97 | // { field: "Name", direction: "asc", },
98 | // ],
99 | fields: fields,
100 | maxRecords: 1,
101 | // view: env.get('AIRTABLE_TABLE_VIEW_COUNTRIES').default('Grid view').asString(),
102 | ...selectArgs
103 | }).firstPage(function page(error, records) {
104 | try {
105 | if (error) console.error(error)
106 | if (error || !records?.length) {
107 | return reject(`No countries was found for filter ${JSON.stringify(selectArgs)}`)
108 | }
109 | const country = records?.[0]._rawJson
110 | resolve(formatCountry(country))
111 | } catch(e) {
112 | console.log("CountryError4")
113 | reject(e)
114 | }
115 | })
116 | })
117 | }
118 |
119 | export async function getCountryByCode (countryCode: string) {
120 | return getCountryBy({
121 | filterByFormula: `{countryCode}="${countryCode.toUpperCase()}"`
122 | })
123 | }
124 |
125 | export async function getCountryBySlug (slug: string) {
126 | return getCountryBy({
127 | filterByFormula: `{Slug}="${slug.toLowerCase()}"`
128 | })
129 | }
130 |
131 |
132 | export type CountryData = {
133 | country: Country
134 | }
135 |
136 | export const getCountryDataByCode = async (iso2: string): Promise => {
137 | if (!iso2 || !/[A-Za-z]{2}/.test(iso2)) {
138 | throw new Error("A two-digit ISO3166a2 country code must be provided.")
139 | }
140 | const country = await getCountryByCode(iso2)
141 | if (!country) {
142 | throw new Error("No such country was found for this country code.")
143 | }
144 |
145 | const solidarityActions = await getLiveSolidarityActionsByCountryCode(iso2)
146 | const organisingGroups = await getOrganisingGroupsByCountryId(country.id)
147 |
148 | return {
149 | country: {
150 | ...country,
151 | organisingGroups,
152 | solidarityActions
153 | }
154 | }
155 | }
156 |
157 | export const getCountryDataBySlug = async (slug: string): Promise => {
158 | if (!/[A-Za-z-]+/.test(slug)) {
159 | throw new Error(`'${slug}' isn't a valid country name`)
160 | }
161 | let country
162 | try {
163 | country = await getCountryBySlug(slug)
164 | if (!country) {
165 | throw new Error(`No country was found called '${slug}'`)
166 | }
167 | } catch (e) {
168 | console.error(e)
169 | throw new Error(`No country was found called '${slug}'`)
170 | }
171 |
172 | const solidarityActions = await getLiveSolidarityActionsByCountryCode(country.fields.countryCode)
173 |
174 | return {
175 | country: {
176 | ...country,
177 | solidarityActions
178 | }
179 | }
180 | }
--------------------------------------------------------------------------------
/data/solidarityAction.ts:
--------------------------------------------------------------------------------
1 | import { SolidarityAction, SolidarityActionAirtableRecord } from './types';
2 | import { airtableBase } from './airtable';
3 | import env from 'env-var';
4 | import { solidarityActionSchema, openStreetMapReverseGeocodeResponseSchema, airtableCDNMapSchema } from './schema';
5 | import { QueryParams } from 'airtable/lib/query_params';
6 | import { airtableFilterAND } from '../utils/airtable';
7 | import { parseMarkdown } from './markdown';
8 | import { geocodeOpenStreetMap } from './geo';
9 | import { countryDataForCode } from './country';
10 | import { generateCDNMap } from './cloudinary';
11 | import { RecordData } from 'airtable';
12 |
13 | export const formatSolidarityAction = async (record: SolidarityActionAirtableRecord): Promise => {
14 | let _action: SolidarityActionAirtableRecord = JSON.parse(JSON.stringify(record))
15 | let action: any = _action
16 | action.summary = parseMarkdown(_action.fields.Summary || '')
17 | action.geography = { country: [] }
18 | action.slug = _action.fields.slug || action.id
19 | action.cdnMap = generateCDNMap(_action)
20 |
21 | let i = 0
22 | for (const countryCode of _action.fields.countryCode || []) {
23 | try {
24 | action.geography.country.push(countryDataForCode(countryCode))
25 | } catch (e) {
26 | console.error(JSON.stringify(action), e)
27 | }
28 | i++;
29 |
30 | // Add city
31 | if (_action.fields.LocationData) {
32 | action.geography.location = JSON.parse(_action.fields.LocationData)
33 | } else if (_action.fields.Location) {
34 | console.log("Fetching location data from OpenStreetMap")
35 | const _data = await geocodeOpenStreetMap(_action.fields.Location!, countryCode)
36 | // @ts-ignore
37 | // Parse and verify the JSON we store in the Airtable
38 | const { data, error } = openStreetMapReverseGeocodeResponseSchema.safeParse(_data)
39 |
40 | if (error) {
41 | // console.error(_action.fields.Name)
42 | // console.error(_data, error)
43 | } else if (data) {
44 | action.geography.location = data
45 | solidarityActionBase().update(_action.id, {
46 | LocationData: JSON.stringify(data)
47 | })
48 | }
49 | }
50 | }
51 |
52 | try {
53 | return solidarityActionSchema.parse(action)
54 | } catch (e) {
55 | console.error(action, e)
56 | throw e
57 | }
58 | }
59 |
60 | export function actionToFeature(action: SolidarityAction): GeoJSON.Feature {
61 | //
62 | return {
63 | "type": "Feature",
64 | "geometry": {
65 | "type": "Point",
66 | "coordinates": [
67 | action.geography.country[0].longitude,
68 | action.geography.country[0].latitude
69 | ]
70 | },
71 | "properties": action
72 | }
73 | }
74 |
75 | const fields: Array = ['hasPassedValidation', 'slug', 'companyName', 'organisingGroupName', 'Organising Groups', 'Company', 'Country', 'LocationData', 'Document', 'countryCode', 'countryName', 'countrySlug', 'LastModified', 'DisplayStyle', 'Name', 'Location', 'Summary', 'Date', 'Link', 'Public', 'Category', 'CategoryName', 'CategoryEmoji', 'cdn_urls']
76 |
77 | // @ts-ignore
78 | export const solidarityActionBase = () => airtableBase()(
79 | env.get('AIRTABLE_TABLE_NAME_SOLIDARITY_ACTIONS').default('Solidarity Actions').asString()
80 | )
81 |
82 | //
83 | export async function getAllSolidarityActions ({ filterByFormula = '', ...selectArgs }: QueryParams = {}): Promise> {
84 | return new Promise((resolve, reject) => {
85 | const solidarityActions: SolidarityActionAirtableRecord[] = []
86 |
87 | solidarityActionBase().select({
88 | filterByFormula,
89 | maxRecords: 1000000,
90 | ...selectArgs
91 | }).eachPage(function page(records, fetchNextPage) {
92 | try {
93 | for (const record of records) {
94 | solidarityActions.push(record._rawJson)
95 | }
96 | fetchNextPage();
97 | } catch (e) {
98 | console.error(e)
99 | reject(e)
100 | }
101 | }, function done(err) {
102 | try {
103 | if (err) { reject(err); return; }
104 | resolve(solidarityActions)
105 | } catch (e) {
106 | console.error(e)
107 | reject(e)
108 | }
109 | })
110 | })
111 | }
112 |
113 | export async function getLiveSolidarityActions ({ filterByFormula, ...selectArgs }: QueryParams = {}): Promise> {
114 | const airtableRecords = await getAllSolidarityActions({
115 | filterByFormula: airtableFilterAND(
116 | 'Public',
117 | 'Name!=""',
118 | 'Date!=""',
119 | filterByFormula
120 | ),
121 | fields: fields,
122 | sort: [
123 | { field: "Date", direction: "desc", },
124 | ],
125 | view: env.get('AIRTABLE_TABLE_VIEW_SOLIDARITY_ACTIONS').default('Live').asString(),
126 | ...selectArgs
127 | })
128 | const outputtedActions: SolidarityAction[] = []
129 | for (const record of airtableRecords) {
130 | try {
131 | outputtedActions.push(await formatSolidarityAction(record))
132 | } catch(e) {
133 | console.error(e)
134 | }
135 | }
136 | return outputtedActions
137 | }
138 |
139 | export async function getLiveSolidarityActionsByCountryCode (iso2: string) {
140 | const filterByFormula = `FIND("${iso2}", ARRAYJOIN({countryCode})) > 0`
141 | return getLiveSolidarityActions({ filterByFormula })
142 | }
143 |
144 | export async function getLiveSolidarityActionsByCompanyId (id: string) {
145 | const filterByFormula = `FIND("${id}", ARRAYJOIN({Company})) > 0`
146 | return getLiveSolidarityActions({ filterByFormula })
147 | }
148 |
149 | export async function getLiveSolidarityActionsByCategoryId (id: string) {
150 | const filterByFormula = `FIND("${id}", ARRAYJOIN({Category})) > 0`
151 | return getLiveSolidarityActions({ filterByFormula })
152 | }
153 |
154 | export async function getLiveSolidarityActionsByOrganisingGroupId (id: string) {
155 | const filterByFormula = `FIND("${id}", ARRAYJOIN({Organising Groups})) > 0`
156 | return getLiveSolidarityActions({ filterByFormula })
157 | }
158 |
159 | export async function getSingleSolidarityAction (id: string) {
160 | const filterByFormula = `OR({slug}="${id}", RECORD_ID()="${id}")`
161 | const actions = await getLiveSolidarityActions({ filterByFormula, maxRecords: 1 })
162 | return actions[0]
163 | }
164 |
165 | export function actionUrl(action: SolidarityAction): string {
166 | return `/action/${action.slug}`
167 | }
168 |
169 | export async function updateSolidarityActions(updates: RecordData[]) {
170 | return new Promise((resolve, reject) => {
171 | solidarityActionBase().update(updates, function (err, records) {
172 | if (err) {
173 | console.error(records)
174 | reject(err)
175 | }
176 | resolve(records)
177 | });
178 | })
179 | }
--------------------------------------------------------------------------------
/data/cdn.ts:
--------------------------------------------------------------------------------
1 | import cloudinary from 'cloudinary'
2 | import env from 'env-var';
3 | import {
4 | SolidarityAction,
5 | AirtableCDNMap,
6 | BlogPost,
7 | FormattedRecordWithCDNMap
8 | } from './types';
9 | import { ensureArray } from '../utils/string';
10 | import { updateSolidarityActions } from './solidarityAction';
11 | import { chunk } from 'lodash';
12 | import { Attachment, RecordData } from 'airtable';
13 | import { updateBlogPosts } from './blogPost';
14 |
15 | export async function syncBlogPostsToCDN(_blog: BlogPost | BlogPost[]) {
16 | return syncAirtableRowToCDN(_blog, 'Image', updateBlogPosts)
17 | }
18 |
19 | export async function syncSolidarityActionsToCDN(_action: SolidarityAction | SolidarityAction[]) {
20 | return syncAirtableRowToCDN(_action, 'Document', updateSolidarityActions)
21 | }
22 |
23 | //////// Generic-ified
24 |
25 | export async function syncAirtableRowToCDN(
26 | _record: Record | Record[],
27 | columnName: string,
28 | updateRecords: (updateList: RecordData[]) => Promise
29 | ) {
30 | const records = ensureArray(_record)
31 | const updateList: Array<{
32 | id: string,
33 | fields: Partial
34 | }> = []
35 | for (const record of records) {
36 | if (record.fields[columnName]?.length) {
37 | // Synchronise Docs and CDN Map column
38 | const missingCDNMapEntry = record.fields[columnName]!.some((doc: Attachment) => !record.cdnMap?.find(cdn => cdn.airtableDocID === doc.id))
39 | const invalidCDNMapEntry = record.cdnMap?.some(cdn => !record.fields[columnName]!.find((doc: Attachment) => doc.id === cdn.airtableDocID))
40 | if (!missingCDNMapEntry || invalidCDNMapEntry) {
41 | // There's a mismatch between the docs and the CDN map, so we need to re-sync
42 | // First upload the docs to the CDN
43 | const cdnPayload = await uploadAirtableFilesToCDN(record, columnName)
44 | // Then sync the public URLs back to Airtable
45 | if (cdnPayload.length > 0) {
46 | updateList.push({
47 | id: record.id,
48 | fields: {
49 | cdn_urls: JSON.stringify(cdnPayload)
50 | }
51 | })
52 | }
53 | }
54 | } else if (record.fields.cdn_urls) {
55 | // Clear CDNs to reflect the fact there are no docs
56 | updateList.push({
57 | id: record.id,
58 | fields: {
59 | cdn_urls: "[]"
60 | }
61 | })
62 | }
63 | }
64 | let recordsUpdated = 0
65 | for (const chunkedUpdate of chunk(updateList, 10)) {
66 | recordsUpdated += (await updateRecords(chunkedUpdate)).length
67 | }
68 | return recordsUpdated
69 | }
70 |
71 | async function uploadAirtableFilesToCDN(record: FormattedRecordWithCDNMap, columnName: string): Promise {
72 | const cdnMap: AirtableCDNMap[] = []
73 | for (const file of ((record.fields[columnName] || []) as Attachment[])) {
74 | try {
75 | const [original, thumbnail] = await Promise.all([
76 | uploadToCDN(file.url, `${file.id}-${encodeURIComponent(file.filename)}`),
77 | file.thumbnails ? uploadToCDN(file.thumbnails.large.url, `${file.id}-${encodeURIComponent(file.filename)}-thumbnail`) : undefined
78 | ])
79 | if (!!original && !!thumbnail) {
80 | cdnMap.push({
81 | filename: file.filename,
82 | filetype: file.type,
83 | airtableDocID: file.id,
84 | originalURL: original.url,
85 | originalWidth: original.width,
86 | originalHeight: original.height,
87 | thumbnailURL: thumbnail.url,
88 | thumbnailWidth: thumbnail.width,
89 | thumbnailHeight: thumbnail.height,
90 | })
91 | }
92 | } catch (e) {
93 | console.error(`Failed to upload ${file.url} to CDN`, e)
94 | }
95 | }
96 | return cdnMap
97 | }
98 |
99 | export async function uploadToCDN(url: string, filename?: string) {
100 | return uploadToCloudinary(url, filename)
101 | }
102 |
103 | export async function uploadToCloudinary(url: string, filename?: string) {
104 | await cloudinary.v2.config({
105 | cloud_name: env.get('CLOUDINARY_NAME').required().asString(),
106 | api_key: env.get('CLOUDINARY_API_KEY').required().asString(),
107 | api_secret: env.get('CLOUDINARY_API_SECRET').required().asString(),
108 | secure: true
109 | });
110 |
111 | const config: cloudinary.UploadApiOptions = {
112 | use_filename: true
113 | }
114 |
115 | // Replace existing files rather than upload a duplicate
116 | // Docs: https://support.cloudinary.com/hc/en-us/articles/202520852-How-can-I-update-an-already-uploaded-image-
117 | if (filename) {
118 | config.public_id = filename
119 | }
120 |
121 | if (filename) {
122 | const ext = filename.split('.').pop()!
123 | const extSupport = cloudinaryFileExtensionSupport[ext]
124 | if (!extSupport || !extSupport.transform || !extSupport.upload) {
125 | config.resource_type = 'raw'
126 | }
127 | }
128 |
129 | return new Promise((resolve, reject) => {
130 | cloudinary.v2.uploader.upload(url, config, (error, result) => {
131 | if (error) {
132 | return reject(error)
133 | }
134 | return resolve(result)
135 | });
136 | })
137 | }
138 |
139 | // https://cloudinary.com/documentation/image_transformations#supported_image_formats
140 | const cloudinaryFileExtensionSupport: { [ext: string]: { upload: boolean, transform: boolean } } = {
141 | "ai": {
142 | "upload": true,
143 | "transform": true
144 | },
145 | "avif": {
146 | "upload": false,
147 | "transform": true
148 | },
149 | "gif": {
150 | "upload": true,
151 | "transform": true
152 | },
153 | "png": {
154 | "upload": true,
155 | "transform": true
156 | },
157 | "webp": {
158 | "upload": true,
159 | "transform": true
160 | },
161 | "bmp": {
162 | "upload": true,
163 | "transform": true
164 | },
165 | "bw": {
166 | "upload": true,
167 | "transform": true
168 | },
169 | "djvu": {
170 | "upload": true,
171 | "transform": false
172 | },
173 | "ps": {
174 | "upload": true,
175 | "transform": true
176 | },
177 | "ept": {
178 | "upload": true,
179 | "transform": true
180 | },
181 | "eps": {
182 | "upload": true,
183 | "transform": true
184 | },
185 | "eps3": {
186 | "upload": true,
187 | "transform": true
188 | },
189 | "fbx": {
190 | "upload": true,
191 | "transform": true
192 | },
193 | "flif": {
194 | "upload": true,
195 | "transform": true
196 | },
197 | "heif": {
198 | "upload": true,
199 | "transform": true
200 | },
201 | "heic": {
202 | "upload": true,
203 | "transform": true
204 | },
205 | "ico": {
206 | "upload": true,
207 | "transform": true
208 | },
209 | "indd": {
210 | "upload": true,
211 | "transform": true
212 | },
213 | "jpg": {
214 | "upload": true,
215 | "transform": true
216 | },
217 | "jpe": {
218 | "upload": true,
219 | "transform": true
220 | },
221 | "jpeg": {
222 | "upload": true,
223 | "transform": true
224 | },
225 | "jp2": {
226 | "upload": true,
227 | "transform": true
228 | },
229 | "wdp": {
230 | "upload": true,
231 | "transform": true
232 | },
233 | "jxr": {
234 | "upload": true,
235 | "transform": true
236 | },
237 | "hdp": {
238 | "upload": true,
239 | "transform": true
240 | },
241 | "obj": {
242 | "upload": true,
243 | "transform": true
244 | },
245 | "pdf": {
246 | "upload": true,
247 | "transform": true
248 | },
249 | "ply": {
250 | "upload": true,
251 | "transform": true
252 | },
253 | "psd": {
254 | "upload": true,
255 | "transform": true
256 | },
257 | "arw": {
258 | "upload": true,
259 | "transform": false
260 | },
261 | "cr2": {
262 | "upload": true,
263 | "transform": false
264 | },
265 | "svg": {
266 | "upload": true,
267 | "transform": true
268 | },
269 | "tga": {
270 | "upload": true,
271 | "transform": true
272 | },
273 | "tif": {
274 | "upload": true,
275 | "transform": true
276 | },
277 | "tiff": {
278 | "upload": true,
279 | "transform": true
280 | },
281 | "u3ma": {
282 | "upload": true,
283 | "transform": true
284 | },
285 | "usdz": {
286 | "upload": true,
287 | "transform": false
288 | }
289 | }
--------------------------------------------------------------------------------
/components/OrganisingGroup.tsx:
--------------------------------------------------------------------------------
1 | import { Dialog, Transition } from "@headlessui/react"
2 | import { OrganisingGroup } from '../data/types';
3 | import { stringifyArray } from '../utils/string';
4 | import Emoji from 'a11y-react-emoji';
5 | import pluralize from 'pluralize';
6 | import { useRouter } from 'next/dist/client/router';
7 | import { NextSeo } from "next-seo";
8 | import cx from 'classnames';
9 | import { SolidarityActionCountryRelatedActions, SolidarityActionRelatedActions } from "./SolidarityActions";
10 | import { projectStrings } from "../data/site";
11 | import { groupUrl } from "../data/organisingGroup";
12 |
13 | export function useSelectedOrganisingGroup(groups: OrganisingGroup[], key = 'dialogOrganisingGroupId') {
14 | const router = useRouter();
15 | const dialogOrganisingGroupId = router.query[key]
16 | const selectedOrganisingGroup = groups.find(a => a.id === dialogOrganisingGroupId)
17 | return [selectedOrganisingGroup, key] as const
18 | }
19 |
20 | export const OrganisingGroupDialog = (
21 | { onClose, data }:
22 | { onClose: () => void, data?: OrganisingGroup }
23 | ) => {
24 | return (
25 |
34 |
39 | {!!data && (
40 | <>
41 |
42 |
43 |
44 | {data.fields.Name}
45 | {data.fields.IsUnion ? "Union" : "Organising group"} in {stringifyArray(...data.fields?.countryName || [])}
46 |
51 | ← Back
52 |
53 |
54 |
55 | >
56 | )}
57 |
58 |
59 | )
60 | }
61 |
62 | export const OrganisingGroupSEO = ({ data }: { data: OrganisingGroup }) => (
63 |
70 | )
71 |
72 | export const OrganisingGroupCard = ({ data, withPadding = true, withContext = true }: { data: OrganisingGroup, withPadding?: boolean, withContext?: boolean }) => {
73 | return (
74 | <>
75 |
76 |
77 |
78 |
79 | {data.fields.IsUnion ? "Union" : "Organising group"}
80 | {data.geography?.country.map(country => (
81 |
82 |
87 | {country.name}
88 |
89 | ))}
90 |
91 |
92 |
93 | {data.fields.Name}
94 |
95 | {data.fields["Full Name"] && (
96 |
97 | {data.fields["Full Name"] && (data.fields["Name"].trim() !== data.fields["Full Name"].trim()) && (
98 |
99 | {data.fields["Full Name"].trimEnd()}
100 |
101 | )}
102 |
{data.fields.IsUnion ? "A union" : "An organising group"} active {
103 | data.fields.countryCode?.length
104 | ? in {pluralize('country', data.fields.countryCode?.length, true)}
105 | : internationally
106 | }. We know of {pluralize('action', data.fields["Solidarity Actions"]?.length, true)} associated with them.
107 |
108 | )}
109 |
140 |
141 |
142 |
143 | Have more info about this {data.fields.IsUnion ? "union" : "organising group"}?
Let us know →
144 |
145 | {withContext && (
146 |
147 |
148 |
154 |
155 | {data.fields.countryCode?.map(code =>
156 |
157 |
160 |
161 | )}
162 |
163 | )}
164 |
165 | >
166 | )
167 | }
--------------------------------------------------------------------------------
/data/types.ts:
--------------------------------------------------------------------------------
1 | ///////////
2 | // Airtable
3 |
4 | export interface BaseRecord {
5 | id: string;
6 | createdTime: string;
7 | }
8 |
9 | export interface BaseRecordWithSyncedCDNMap extends BaseRecord {
10 | fields: { cdn_urls?: string }
11 | }
12 |
13 | export interface FormattedRecordWithCDNMap extends BaseRecordWithSyncedCDNMap {
14 | cdnMap: Array
15 | }
16 |
17 | export interface Attachment {
18 | id: string;
19 | url: string;
20 | filename: string;
21 | size: number;
22 | type: string;
23 | thumbnails: Thumbnails;
24 | }
25 |
26 | export interface Thumbnails {
27 | small: Thumbnail;
28 | large: Thumbnail;
29 | full?: Thumbnail;
30 | }
31 |
32 | export interface Thumbnail {
33 | url: string;
34 | width: number;
35 | height: number;
36 | }
37 |
38 | ////// Package and third party
39 |
40 | /**
41 | * From the "country-flag-emoji" npm package
42 | */
43 | export interface CountryEmoji {
44 | code: string,
45 | unicode: string
46 | name: string
47 | emoji: string
48 | }
49 |
50 | export interface OpenStreetMapReverseGeocodeResponse {
51 | place_id: number;
52 | licence: string;
53 | osm_type: string;
54 | osm_id: number;
55 | lat: string;
56 | lon: string;
57 | place_rank: number;
58 | category: string;
59 | type: string;
60 | importance: number;
61 | addresstype?: string;
62 | name?: string;
63 | display_name: string;
64 | address?: Address;
65 | boundingbox: string[];
66 | }
67 |
68 | export interface Address {
69 | continent?: string
70 |
71 | country?: string
72 | country_code?: string
73 |
74 | region?: string
75 | state?: string
76 | state_district?: string
77 | county?: string
78 |
79 | municipality?: string
80 | city?: string
81 | town?: string
82 | village?: string
83 |
84 | city_district?: string
85 | district?: string
86 | borough?: string
87 | suburb?: string
88 | subdivision?: string
89 |
90 | hamlet?: string
91 | croft?: string
92 | isolated_dwelling?: string
93 |
94 | neighbourhood?: string
95 | allotments?: string
96 | quarter?: string
97 |
98 | city_block?: string
99 | residental?: string
100 | farm?: string
101 | farmyard?: string
102 | industrial?: string
103 | commercial?: string
104 | retail?: string
105 |
106 | road?: string
107 |
108 | house_number?: string
109 | house_name?: string
110 |
111 | emergency?: string
112 | historic?: string
113 | military?: string
114 | natural?: string
115 | landuse?: string
116 | place?: string
117 | railway?: string
118 | man_made?: string
119 | aerialway?: string
120 | boundary?: string
121 | amenity?: string
122 | aeroway?: string
123 | club?: string
124 | craft?: string
125 | leisure?: string
126 | office?: string
127 | mountain_pass?: string
128 | shop?: string
129 | tourism?: string
130 | bridge?: string
131 | tunnel?: string
132 | waterway?: string
133 | }
134 |
135 | //////////////
136 | // Domain data
137 |
138 | export type CopyType = {
139 | html: string
140 | plaintext: string
141 | }
142 |
143 | export type Geography = {
144 | country: Array<{
145 | name: string
146 | emoji: CountryEmoji
147 | iso3166: string
148 | latitude: number,
149 | longitude: number
150 | bbox: [number, number, number, number]
151 | }>,
152 | location?: OpenStreetMapReverseGeocodeResponse
153 | }
154 |
155 |
156 | export interface SolidarityActionAirtableRecord extends BaseRecordWithSyncedCDNMap {
157 | fields: {
158 | slug?: string
159 | Name?: string;
160 | Location?: string;
161 | Summary?: string;
162 | Date?: string;
163 | LastModified?: string;
164 | Link?: string;
165 | LocationData?: string; // OpenStreetMapReverseGeocodeResponse;
166 | Country?: string[]
167 | 'countryName'?: string[]
168 | companyName?: string[]
169 | organisingGroupName?: string[]
170 | 'countryCode'?: string[]
171 | 'countrySlug'?: string[]
172 | 'Company'?: string[],
173 | 'Organising Groups'?: string[]
174 | Category?: string[],
175 | CategoryName?: string[],
176 | CategoryEmoji?: string[],
177 | Document?: Attachment[];
178 | DisplayStyle?: "Featured" | null
179 | hasPassedValidation?: boolean,
180 | Public?: boolean
181 | } & BaseRecordWithSyncedCDNMap['fields']
182 | }
183 |
184 | export type SolidarityAction = SolidarityActionAirtableRecord & FormattedRecordWithCDNMap & {
185 | geography: Geography,
186 | summary: CopyType
187 | slug: string
188 | fields: SolidarityActionAirtableRecord['fields'] & {
189 | Name: string;
190 | Date: string;
191 | Public: true;
192 | LastModified: string;
193 | hasPassedValidation: true,
194 | }
195 | }
196 |
197 | export interface BlogPostAirtableRecord extends BaseRecordWithSyncedCDNMap {
198 | fields: {
199 | Slug?: string;
200 | ByLine?: string
201 | Title: string;
202 | readonly Image?: Attachment[];
203 | Summary?: string;
204 | Body: string;
205 | Date: string;
206 | Public: true; // We can't accept records that haven't been marked for publication
207 | } & BaseRecordWithSyncedCDNMap['fields'],
208 | body: CopyType
209 | }
210 |
211 | export type BlogPost = FormattedRecordWithCDNMap & BlogPostAirtableRecord
212 |
213 | export interface StaticPage extends BaseRecord {
214 | fields: {
215 | Slug?: string;
216 | Title: string;
217 | Summary?: string;
218 | Body: string;
219 | Public: true; // We can't accept records that haven't been marked for publication
220 | },
221 | body: CopyType
222 | }
223 |
224 | export interface Country extends BaseRecord {
225 | emoji: CountryEmoji
226 | fields: {
227 | Name: string;
228 | countryCode: string;
229 | Summary?: string;
230 | Slug: string
231 | Unions?: string[]
232 | unionNames?: string[]
233 | // 'Official Name': string;
234 | 'Solidarity Actions'?: string[]
235 | // 'DisplayStyle (from Solidarity Actions)': string[]
236 | // 'Category (from Solidarity Actions)': string[]
237 | // 'Document (from Solidarity Actions)': Attachment[]
238 | // 'Date (from Solidarity Actions)': string[]
239 | // 'Name (from Solidarity Actions)': string[]
240 | }
241 | solidarityActions?: SolidarityAction[],
242 | organisingGroups?: OrganisingGroup[],
243 | summary: CopyType
244 | }
245 |
246 | export interface OrganisingGroup extends BaseRecord {
247 | geography: Pick
248 | slug: string
249 | solidarityActions?: SolidarityAction[]
250 | fields: {
251 | slug?: string
252 | Name: string
253 | 'Full Name'?: string
254 | Country?: string[]
255 | countryName?: string[]
256 | countryCode?: string[]
257 | IsUnion?: boolean
258 | Website?: string
259 | Bluesky?: string
260 | Twitter?: string
261 | // 'Official Name': string;
262 | 'Solidarity Actions'?: string[]
263 | LastModified: string
264 | // 'DisplayStyle (from Solidarity Actions)': string[]
265 | // 'Category (from Solidarity Actions)': string[]
266 | // 'Document (from Solidarity Actions)': Attachment[]
267 | // 'Date (from Solidarity Actions)': string[]
268 | // 'Name (from Solidarity Actions)': string[]
269 | }
270 | }
271 |
272 | export interface Company extends BaseRecord {
273 | fields: {
274 | Name: string;
275 | Summary?: string;
276 | // 'Official Name': string;
277 | 'Solidarity Actions'?: string[]
278 | // 'DisplayStyle (from Solidarity Actions)': string[]
279 | // 'Category (from Solidarity Actions)': string[]
280 | // 'Document (from Solidarity Actions)': Attachment[]
281 | // 'Date (from Solidarity Actions)': string[]
282 | // 'Name (from Solidarity Actions)': string[]
283 | }
284 | solidarityActions?: SolidarityAction[],
285 | summary: CopyType
286 | }
287 |
288 | export interface Category extends BaseRecord {
289 | fields: {
290 | Name: string;
291 | Emoji: string;
292 | Summary?: string;
293 | // 'Official Name': string;
294 | 'Solidarity Actions'?: string[]
295 | // 'DisplayStyle (from Solidarity Actions)': string[]
296 | // 'Category (from Solidarity Actions)': string[]
297 | // 'Document (from Solidarity Actions)': Attachment[]
298 | // 'Date (from Solidarity Actions)': string[]
299 | // 'Name (from Solidarity Actions)': string[]
300 | }
301 | solidarityActions?: SolidarityAction[],
302 | summary: CopyType
303 | }
304 |
305 | export interface MenuItem extends BaseRecord {
306 | fields: {
307 | label: string;
308 | url: string;
309 | placement: Array<'Header' | 'Footer'>;
310 | }
311 | }
312 |
313 | ///
314 |
315 | export interface AirtableCDNMap {
316 | filename: string
317 | filetype: string
318 | airtableDocID: string
319 | originalURL: string
320 | originalWidth?: number
321 | originalHeight?: number
322 | thumbnailURL?: string
323 | thumbnailWidth?: number
324 | thumbnailHeight?: number
325 | }
--------------------------------------------------------------------------------
/data/schema.ts:
--------------------------------------------------------------------------------
1 | // Generated by ts-to-zod
2 | import { z } from "zod";
3 |
4 | export const baseRecordSchema = z.object({
5 | id: z.string(),
6 | createdTime: z.string(),
7 | });
8 |
9 | export const baseRecordWithSyncedCDNMapSchema = baseRecordSchema.extend({
10 | fields: z.object({
11 | cdn_urls: z.string().optional(),
12 | }),
13 | });
14 |
15 | export const thumbnailSchema = z.object({
16 | url: z.string(),
17 | width: z.number(),
18 | height: z.number(),
19 | });
20 |
21 | export const countryEmojiSchema = z.object({
22 | code: z.string(),
23 | unicode: z.string(),
24 | name: z.string(),
25 | emoji: z.string(),
26 | });
27 |
28 | export const addressSchema = z.object({
29 | continent: z.string().optional(),
30 | country: z.string().optional(),
31 | country_code: z.string().optional(),
32 | region: z.string().optional(),
33 | state: z.string().optional(),
34 | state_district: z.string().optional(),
35 | county: z.string().optional(),
36 | municipality: z.string().optional(),
37 | city: z.string().optional(),
38 | town: z.string().optional(),
39 | village: z.string().optional(),
40 | city_district: z.string().optional(),
41 | district: z.string().optional(),
42 | borough: z.string().optional(),
43 | suburb: z.string().optional(),
44 | subdivision: z.string().optional(),
45 | hamlet: z.string().optional(),
46 | croft: z.string().optional(),
47 | isolated_dwelling: z.string().optional(),
48 | neighbourhood: z.string().optional(),
49 | allotments: z.string().optional(),
50 | quarter: z.string().optional(),
51 | city_block: z.string().optional(),
52 | residental: z.string().optional(),
53 | farm: z.string().optional(),
54 | farmyard: z.string().optional(),
55 | industrial: z.string().optional(),
56 | commercial: z.string().optional(),
57 | retail: z.string().optional(),
58 | road: z.string().optional(),
59 | house_number: z.string().optional(),
60 | house_name: z.string().optional(),
61 | emergency: z.string().optional(),
62 | historic: z.string().optional(),
63 | military: z.string().optional(),
64 | natural: z.string().optional(),
65 | landuse: z.string().optional(),
66 | place: z.string().optional(),
67 | railway: z.string().optional(),
68 | man_made: z.string().optional(),
69 | aerialway: z.string().optional(),
70 | boundary: z.string().optional(),
71 | amenity: z.string().optional(),
72 | aeroway: z.string().optional(),
73 | club: z.string().optional(),
74 | craft: z.string().optional(),
75 | leisure: z.string().optional(),
76 | office: z.string().optional(),
77 | mountain_pass: z.string().optional(),
78 | shop: z.string().optional(),
79 | tourism: z.string().optional(),
80 | bridge: z.string().optional(),
81 | tunnel: z.string().optional(),
82 | waterway: z.string().optional(),
83 | });
84 |
85 | export const copyTypeSchema = z.object({
86 | html: z.string(),
87 | plaintext: z.string(),
88 | });
89 |
90 | export const staticPageSchema = baseRecordSchema.extend({
91 | fields: z.object({
92 | Slug: z.string().optional(),
93 | Title: z.string(),
94 | Summary: z.string().optional(),
95 | Body: z.string(),
96 | Public: z.literal(true),
97 | }),
98 | body: copyTypeSchema,
99 | });
100 |
101 | export const menuItemSchema = baseRecordSchema.extend({
102 | fields: z.object({
103 | label: z.string(),
104 | url: z.string(),
105 | placement: z.array(z.union([z.literal("Header"), z.literal("Footer")])),
106 | }),
107 | });
108 |
109 | export const airtableCDNMapSchema = z.object({
110 | filename: z.string(),
111 | filetype: z.string(),
112 | airtableDocID: z.string(),
113 | originalURL: z.string(),
114 | originalWidth: z.number().optional(),
115 | originalHeight: z.number().optional(),
116 | thumbnailURL: z.string().optional(),
117 | thumbnailWidth: z.number().optional(),
118 | thumbnailHeight: z.number().optional(),
119 | });
120 |
121 | export const formattedRecordWithCDNMapSchema = baseRecordWithSyncedCDNMapSchema.extend(
122 | {
123 | cdnMap: z.array(airtableCDNMapSchema),
124 | }
125 | );
126 |
127 | export const thumbnailsSchema = z.object({
128 | small: thumbnailSchema,
129 | large: thumbnailSchema,
130 | full: thumbnailSchema.optional(),
131 | });
132 |
133 | export const openStreetMapReverseGeocodeResponseSchema = z.object({
134 | place_id: z.number(),
135 | licence: z.string(),
136 | osm_type: z.string(),
137 | osm_id: z.number(),
138 | lat: z.string(),
139 | lon: z.string(),
140 | place_rank: z.number(),
141 | category: z.string(),
142 | type: z.string(),
143 | importance: z.number(),
144 | addresstype: z.string().optional(),
145 | name: z.string().optional(),
146 | display_name: z.string(),
147 | address: addressSchema.optional(),
148 | boundingbox: z.array(z.string()),
149 | });
150 |
151 | export const geographySchema = z.object({
152 | country: z.array(
153 | z.object({
154 | name: z.string(),
155 | emoji: countryEmojiSchema,
156 | iso3166: z.string(),
157 | latitude: z.number(),
158 | longitude: z.number(),
159 | bbox: z.tuple([z.number(), z.number(), z.number(), z.number()]),
160 | })
161 | ),
162 | location: openStreetMapReverseGeocodeResponseSchema.optional(),
163 | });
164 |
165 | export const attachmentSchema = z.object({
166 | id: z.string(),
167 | url: z.string(),
168 | filename: z.string(),
169 | size: z.number(),
170 | type: z.string(),
171 | thumbnails: thumbnailsSchema,
172 | });
173 |
174 | export const solidarityActionAirtableRecordSchema = baseRecordWithSyncedCDNMapSchema.extend(
175 | {
176 | fields: z
177 | .object({
178 | slug: z.string().optional(),
179 | Name: z.string().optional(),
180 | Location: z.string().optional(),
181 | Summary: z.string().optional(),
182 | Date: z.string().optional(),
183 | LastModified: z.string().optional(),
184 | Link: z.string().optional(),
185 | LocationData: z.string().optional(),
186 | Country: z.array(z.string()).optional(),
187 | countryName: z.array(z.string()).optional(),
188 | companyName: z.array(z.string()).optional(),
189 | organisingGroupName: z.array(z.string()).optional(),
190 | countryCode: z.array(z.string()).optional(),
191 | countrySlug: z.array(z.string()).optional(),
192 | Company: z.array(z.string()).optional(),
193 | "Organising Groups": z.array(z.string()).optional(),
194 | Category: z.array(z.string()).optional(),
195 | CategoryName: z.array(z.string()).optional(),
196 | CategoryEmoji: z.array(z.string()).optional(),
197 | Document: z.array(attachmentSchema).optional(),
198 | DisplayStyle: z.literal("Featured").optional().nullable(),
199 | hasPassedValidation: z.boolean().optional(),
200 | Public: z.boolean().optional(),
201 | })
202 | .and(baseRecordWithSyncedCDNMapSchema.shape.fields),
203 | }
204 | );
205 |
206 | export const solidarityActionSchema = solidarityActionAirtableRecordSchema
207 | .and(formattedRecordWithCDNMapSchema)
208 | .and(
209 | z.object({
210 | geography: geographySchema,
211 | summary: copyTypeSchema,
212 | slug: z.string(),
213 | fields: solidarityActionAirtableRecordSchema.shape.fields.and(
214 | z.object({
215 | Name: z.string(),
216 | Date: z.string(),
217 | Public: z.literal(true),
218 | LastModified: z.string(),
219 | hasPassedValidation: z.literal(true),
220 | })
221 | ),
222 | })
223 | );
224 |
225 | export const blogPostAirtableRecordSchema = baseRecordWithSyncedCDNMapSchema.extend(
226 | {
227 | fields: z
228 | .object({
229 | Slug: z.string().optional(),
230 | ByLine: z.string().optional(),
231 | Title: z.string(),
232 | Image: z.array(attachmentSchema).optional(),
233 | Summary: z.string().optional(),
234 | Body: z.string(),
235 | Date: z.string(),
236 | Public: z.literal(true),
237 | })
238 | .and(baseRecordWithSyncedCDNMapSchema.shape.fields),
239 | body: copyTypeSchema,
240 | }
241 | );
242 |
243 | export const blogPostSchema = formattedRecordWithCDNMapSchema.and(
244 | blogPostAirtableRecordSchema
245 | );
246 |
247 | export const organisingGroupSchema = baseRecordSchema.extend({
248 | geography: geographySchema.pick({ country: true }),
249 | slug: z.string(),
250 | solidarityActions: z.array(solidarityActionSchema).optional(),
251 | fields: z.object({
252 | slug: z.string().optional(),
253 | Name: z.string(),
254 | "Full Name": z.string().optional(),
255 | Country: z.array(z.string()).optional(),
256 | countryName: z.array(z.string()).optional(),
257 | countryCode: z.array(z.string()).optional(),
258 | IsUnion: z.boolean().optional(),
259 | Website: z.string().optional(),
260 | Bluesky: z.string().optional(),
261 | Twitter: z.string().optional(),
262 | "Solidarity Actions": z.array(z.string()).optional(),
263 | LastModified: z.string(),
264 | }),
265 | });
266 |
267 | export const companySchema = baseRecordSchema.extend({
268 | fields: z.object({
269 | Name: z.string(),
270 | Summary: z.string().optional(),
271 | "Solidarity Actions": z.array(z.string()).optional(),
272 | }),
273 | solidarityActions: z.array(solidarityActionSchema).optional(),
274 | summary: copyTypeSchema,
275 | });
276 |
277 | export const categorySchema = baseRecordSchema.extend({
278 | fields: z.object({
279 | Name: z.string(),
280 | Emoji: z.string(),
281 | Summary: z.string().optional(),
282 | "Solidarity Actions": z.array(z.string()).optional(),
283 | }),
284 | solidarityActions: z.array(solidarityActionSchema).optional(),
285 | summary: copyTypeSchema,
286 | });
287 |
288 | export const countrySchema = baseRecordSchema.extend({
289 | emoji: countryEmojiSchema,
290 | fields: z.object({
291 | Name: z.string(),
292 | countryCode: z.string(),
293 | Summary: z.string().optional(),
294 | Slug: z.string(),
295 | Unions: z.array(z.string()).optional(),
296 | unionNames: z.array(z.string()).optional(),
297 | "Solidarity Actions": z.array(z.string()).optional(),
298 | }),
299 | solidarityActions: z.array(solidarityActionSchema).optional(),
300 | organisingGroups: z.array(organisingGroupSchema).optional(),
301 | summary: copyTypeSchema,
302 | });
303 |
--------------------------------------------------------------------------------
/components/Map.tsx:
--------------------------------------------------------------------------------
1 | import bbox from '@turf/bbox';
2 | import combine from '@turf/combine';
3 | import ReactMapGL, { Layer, MapContext, Marker, Popup, Source } from '@urbica/react-map-gl';
4 | import Cluster from '@urbica/react-map-gl-cluster';
5 | import Emoji from 'a11y-react-emoji';
6 | import cx from 'classnames';
7 | import { max, median, min } from 'd3-array';
8 | import { scalePow } from 'd3-scale';
9 | import { format } from 'date-fns';
10 | import env from 'env-var';
11 | import { Dictionary, groupBy, merge } from 'lodash';
12 | import { Map as MapboxMap } from 'mapbox-gl';
13 | import 'mapbox-gl/dist/mapbox-gl.css';
14 | import { useContextualRouting } from 'next-use-contextual-routing';
15 | import { useRouter } from 'next/dist/client/router';
16 | import pluralize from 'pluralize';
17 | import { createContext, Dispatch, memo, SetStateAction, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
18 | import { createPortal } from 'react-dom';
19 | import Supercluster from 'supercluster';
20 | import { theme } from 'twin.macro';
21 | import { actionUrl } from '../data/solidarityAction';
22 | import { SolidarityAction } from '../data/types';
23 | import { bboxToBounds, getViewportForFeatures } from '../utils/geo';
24 | import { ActionMetadata, DEFAULT_ACTION_DIALOG_KEY } from './SolidarityActions';
25 | import { FilterContext } from './Timeline';
26 |
27 | const defaultViewport = {
28 | latitude: 15,
29 | longitude: 0,
30 | zoom: 0.7,
31 | }
32 |
33 | const ViewportContext = createContext(defaultViewport)
34 |
35 | const OpenFullScreenSVG = ( )
36 |
37 | const CloseFullScreenSVG = ( );
38 |
39 | function createIdFromActions(actions) {
40 | return actions.map(({ id }) => id).join('-')
41 | }
42 |
43 | export function Map({ data, onSelectCountry, ...initialViewport }: {
44 | data: SolidarityAction[], width?: any, height?: any, onSelectCountry?: (iso2id: string | null) => void
45 | }) {
46 | const [viewport, setViewport] = useState({
47 | ...defaultViewport,
48 | ...initialViewport,
49 | });
50 |
51 | const updateViewport = useCallback(nextViewport => setViewport(nextViewport), [])
52 |
53 | const mapRef = useRef<{ _map: MapboxMap }>(null)
54 |
55 | const { countries, hasFilters } = useContext(FilterContext)
56 | const displayStyle = !hasFilters ? 'summary' : 'detail'
57 |
58 | const countryCounts = useMemo(() => {
59 | const counts = data.reduce((countries, action) => {
60 | for (const code of action.fields.countryCode || []) {
61 | countries[code] ??= 0
62 | countries[code]++
63 | }
64 |
65 | return countries
66 | }, {} as CountryCounts)
67 |
68 | const domain = Object.values(counts)
69 |
70 | const colorScale = scalePow()
71 | .exponent(0.5)
72 | .domain([min(domain), median(domain), max(domain)] as number[])
73 | .range([theme`colors.gwBlue`, theme`colors.gwPink`, theme`colors.gwOrange`] as any)
74 |
75 | for (const code in counts) {
76 | const count = counts[code]
77 | counts[code] = colorScale(count)
78 | }
79 |
80 | return counts
81 | }, [data])
82 |
83 | const _cluster = useRef<{ _cluster: Supercluster<{ props: Parameters[0] }>}>(null)
84 |
85 | function groupActionsByCountry (actions: SolidarityAction[]) {
86 | const actionsWithSingleCountry = actions.reduce((actions, action) => {
87 | action.geography.country.forEach((c, i) => {
88 | actions.push(merge(action, {
89 | geography: {
90 | country: [action.geography.country[i]]
91 | },
92 | fields: {
93 | countryCode: [action.fields.countryCode?.[i]],
94 | countryName: [action.fields.countryName?.[i]],
95 | countrySlug: [action.fields.countrySlug?.[i]],
96 | Country: [action.fields.Country?.[i]],
97 | }
98 | } as Partial))
99 | })
100 | return actions
101 | }, [] as SolidarityAction[])
102 |
103 | return groupBy(actionsWithSingleCountry, d => d.geography.country[0].iso3166)
104 | }
105 |
106 | const nationalActionsByCountryNoLocation = useMemo(() => {
107 | return groupActionsByCountry(data.filter(d => !d.geography.location))
108 | }, [data])
109 |
110 | const nationalActionsByCountry = useMemo(() => {
111 | return groupActionsByCountry(data)
112 | }, [data])
113 |
114 | const allActionsSingleCountry = useMemo(() => {
115 | return Object.values(nationalActionsByCountry).reduce((arr, a) => arr.concat(a), [])
116 | }, [nationalActionsByCountry])
117 |
118 | function calculateViewportForActions () {
119 | const setOfCountryBBOXes = Array.from(new Set(allActionsSingleCountry.map(d => d.geography.country[0].bbox)))
120 |
121 | const FeatureCollection: GeoJSON.FeatureCollection = {
122 | type: 'FeatureCollection',
123 | features: setOfCountryBBOXes.map(bbox => {
124 | return {
125 | type: 'Feature',
126 | properties: {},
127 | geometry: {
128 | type: 'Polygon',
129 | "coordinates": [bboxToBounds(bbox)]
130 | }
131 | }
132 | })
133 | }
134 |
135 | const nextViewport = getViewportForFeatures(
136 | {
137 | ...viewport,
138 | width: mapRef.current?._map.getCanvas().clientWidth || 0,
139 | height: mapRef.current?._map.getCanvas().clientHeight || 0
140 | },
141 | bbox(combine(FeatureCollection)) as any,
142 | { padding: 50 }
143 | )
144 | if (nextViewport) {
145 | setViewport({
146 | ...nextViewport,
147 | zoom: Math.min(10, nextViewport.zoom)
148 | })
149 | }
150 | }
151 |
152 | useEffect(() => {
153 | calculateViewportForActions()
154 | }, [allActionsSingleCountry, nationalActionsByCountry, data])
155 |
156 | const [openPopupId, setSelectedPopup] = useState(null)
157 |
158 | const el = (
159 |
160 |
167 |
180 |
181 |
187 | {/* National events */}
188 | {displayStyle === 'detail' && Object.entries(nationalActionsByCountryNoLocation).map(([countryCode, actionsUnlocated]) => {
189 | const clusterMarkerId = createIdFromActions(actionsUnlocated)
190 | return (
191 |
199 | }
200 | isSelected={clusterMarkerId === openPopupId}
201 | setSelected={setSelectedPopup}
202 | />
203 | )
204 | })}
205 | {/* Location-specific markers */}
206 | {displayStyle === 'detail' && (
207 | {
208 | const actions = _cluster.current?._cluster.getLeaves(cluster.clusterId).map(p => p.properties.props.data)
209 | const clusterMarkerId = createIdFromActions(actions)
210 |
211 | return (
212 |
220 | )
221 | }}>
222 | {data.filter(d => !!d.geography.location).map(d => (
223 |
224 | ))}
225 |
226 | )}
227 |
228 |
229 |
230 | );
231 |
232 | return el
233 | }
234 |
235 | function ActionSource ({ data }: { data: SolidarityAction[] }) {
236 | return (
237 | {
240 | const coords = getCoordinatesForAction(d)
241 | return {
242 | type: "Feature",
243 | id: d.id,
244 | properties: d,
245 | geometry: {
246 | type: "Point",
247 | coordinates: [coords.longitude, coords.latitude]
248 | }
249 | }
250 | })
251 | }} />
252 | )
253 | }
254 |
255 | type CountryCounts = { [iso2: string]: number }
256 |
257 | const CountryLayer = memo(({
258 | mode,
259 | countryCounts,
260 | countryActions,
261 | onSelectCountry
262 | }: {
263 | mode: 'summary' | 'detail'
264 | countryCounts: CountryCounts
265 | countryActions: Dictionary
266 | onSelectCountry: any
267 | }) => {
268 | const [event, setEvent] = useState<{ lng: number, lat: number }>()
269 | const [hoverCountry, setHoverCountry] = useState<{
270 | color_group: number
271 | disputed: string
272 | iso_3166_1: string
273 | iso_3166_1_alpha_3: string
274 | name: string
275 | name_en: string
276 | region: string
277 | subregion: string
278 | wikidata_id: string
279 | worldview: string
280 | }>()
281 | const map = useContext(MapContext)
282 |
283 | // Reset the popup when you switch between summary and detail view
284 | const router = useRouter()
285 | useEffect(() => {
286 | const handleChange = (url, obj) => {
287 | setHoverCountry(undefined)
288 | setEvent(undefined)
289 | }
290 | router.events.on('routeChangeComplete', handleChange)
291 | return () => router.events.off('routeChangeComplete', handleChange)
292 | }, [])
293 |
294 | return (
295 | <>
296 |
300 |
320 | {
322 | const country = event.features?.[0]?.properties
323 | if (mode === 'summary') {
324 | if (country && Object.keys(countryCounts).includes(country.iso_3166_1)) {
325 | if (country.iso_3166_1 === hoverCountry?.iso_3166_1) {
326 | setEvent(undefined)
327 | setHoverCountry(undefined)
328 | } else {
329 | setEvent(event.lngLat)
330 | setHoverCountry(event.features?.[0]?.properties)
331 | }
332 | }
333 | }
334 | }}
335 | onHover={event => {
336 | const country = event.features?.[0]?.properties
337 | if (country && Object.keys(countryCounts).includes(country.iso_3166_1)) {
338 | map.getCanvas().style.cursor = 'pointer'
339 | }
340 | }}
341 | onLeave={event => {
342 | map.getCanvas().style.cursor = ''
343 | }}
344 | {...{
345 | "id": "undisputed country boundary fill hoverable",
346 | "source": "country-boundaries",
347 | "source-layer": "country_boundaries",
348 | "type": "fill",
349 | "filter": [ "==", [ "get", "disputed" ], "false" ],
350 | "paint": {
351 | "fill-color": 'rgba(0,0,0,0)',
352 | }
353 | }}
354 | />
355 | {event && event.lat && event.lng && hoverCountry && (
356 |
360 | )}
361 | >
362 | )
363 | })
364 |
365 | const CountryPopup = memo(({ lat, lng, actions }: {
366 | lat: number
367 | lng: number
368 | actions: SolidarityAction[]
369 | }) => {
370 | const router = useRouter()
371 | const exampleAction = actions?.[0]
372 | return !exampleAction ? null : (
373 |
374 | router.push(
377 | `/?country=${exampleAction.fields.countrySlug?.[0] || ''}`,
378 | undefined,
379 | { shallow: false, scroll: false }
380 | )}
381 | >
382 |
383 |
384 |
385 | {exampleAction.geography.country[0].name}
386 |
387 |
388 | {pluralize('action', actions.length, true)}
389 |
390 |
391 | View
392 |
393 |
394 |
395 | )
396 | })
397 |
398 | function getCoordinatesForAction(data: SolidarityAction) {
399 | let geoData = {
400 | latitude: data.geography.country[0]?.latitude,
401 | longitude: data.geography.country[0]?.longitude
402 | }
403 | if (data?.geography?.location) {
404 | geoData = {
405 | latitude: parseFloat(data.geography.location.lat),
406 | longitude: parseFloat(data.geography.location.lon),
407 | }
408 | }
409 | return geoData
410 | }
411 |
412 | const MapMarker = ({ data, ...coords }: { data: SolidarityAction, latitude: number, longitude: number }) => {
413 | const context = useContext(ViewportContext)
414 | const router = useRouter()
415 | const { makeContextualHref, returnHref }= useContextualRouting()
416 |
417 | return (
418 |
419 | {
420 | e.preventDefault()
421 | router.push(
422 | makeContextualHref({ [DEFAULT_ACTION_DIALOG_KEY]: data.slug }),
423 | actionUrl(data),
424 | { shallow: true }
425 | )
426 | }}>
427 |
428 |
429 | {!!data.fields?.CategoryEmoji?.length && (
430 |
431 | )}
432 | {format(new Date(data.fields.Date), "MMM ''yy")}
433 |
434 |
435 |
436 |
437 | )
438 | }
439 |
440 | const ClusterMarker = ({ longitude, latitude, actions, label, isSelected, setSelected, clusterMarkerId }: {
441 | clusterMarkerId: string
442 | longitude: number
443 | latitude: number
444 | actions: SolidarityAction[],
445 | label?: any
446 | isSelected: boolean
447 | setSelected: Dispatch>
448 | }) => {
449 | const router = useRouter()
450 | const { makeContextualHref, returnHref }= useContextualRouting()
451 |
452 | const marker = useRef()
453 |
454 | useEffect(() => {
455 | if (marker.current._el) {
456 | if (isSelected) {
457 | (marker.current._el as HTMLDivElement).classList.add('z-30')
458 | } else {
459 | (marker.current._el as HTMLDivElement).classList.remove('z-30')
460 | }
461 | }
462 | }, [isSelected])
463 |
464 | return (
465 |
466 | {
468 | if (isSelected) {
469 | setSelected(null)
470 | } else {
471 | setSelected(clusterMarkerId)
472 | }
473 | }}
474 | className='relative'
475 | >
476 |
477 |
478 | {label || actions
479 | .reduce((categories, action) => {
480 | return Array.from(new Set(categories.concat(action.fields?.CategoryEmoji || [])))
481 | }, [] as string[])
482 | .map(emoji =>
483 |
484 | )
485 | }
486 |
487 |
488 | {actions.length}
489 |
490 |
491 | {isSelected && (
492 |
493 | {actions.filter(Boolean).map(action => (
494 |
497 |
{
499 | router.push(
500 | makeContextualHref({ [DEFAULT_ACTION_DIALOG_KEY]: action.slug }),
501 | actionUrl(action),
502 | { shallow: true }
503 | )
504 | }}
505 | className='hover:bg-gwOrangeLight transition duration-75 p-1 rounded-md'
506 | >
507 |
508 |
{action.fields.Name}
509 |
510 |
511 | ))}
512 |
513 | )}
514 |
515 |
516 | )
517 | }
--------------------------------------------------------------------------------
/components/SolidarityActions.tsx:
--------------------------------------------------------------------------------
1 | import { Dialog, Transition } from '@headlessui/react';
2 | import Emoji from 'a11y-react-emoji';
3 | import cx from 'classnames';
4 | import { format, getYear } from 'date-fns';
5 | import Fuse from 'fuse.js';
6 | import { NextSeo } from 'next-seo';
7 | import { useContextualRouting } from 'next-use-contextual-routing';
8 | import { useRouter } from 'next/dist/client/router';
9 | import Image from 'next/image';
10 | import Link from 'next/link';
11 | import pluralize from 'pluralize';
12 | import qs from 'query-string';
13 | import { useContext, useEffect, useMemo, useState } from 'react';
14 | import Highlighter, { Chunk } from "react-highlight-words";
15 | import useSWR from 'swr';
16 | import { FilterContext } from '../components/Timeline';
17 | import { projectStrings } from '../data/site';
18 | import { actionUrl } from '../data/solidarityAction';
19 | import { AirtableCDNMap, Attachment, Country, SolidarityAction } from '../data/types';
20 | import { usePrevious } from '../utils/state';
21 | import { DateTime } from './Date';
22 | import { defaultOGImageStack } from '../pages/_app';
23 |
24 | interface ListProps {
25 | data: SolidarityAction[],
26 | withDialog?: boolean,
27 | gridStyle?: string,
28 | dialogProps?: Partial,
29 | mini?: boolean
30 | }
31 |
32 | interface DialogProps {
33 | selectedAction?: SolidarityAction,
34 | returnHref?: string
35 | cardProps?: Partial
36 | key?: string
37 | }
38 |
39 | interface CardProps {
40 | data: SolidarityAction,
41 | withContext?: boolean
42 | contextProps?: Partial
43 | }
44 |
45 | interface ContextProps {
46 | subtitle?: string
47 | url?: string
48 | name?: any
49 | metadata?: any
50 | buttonLabel?: any
51 | }
52 |
53 | export function SolidarityActionDialog({ selectedAction, returnHref, cardProps }: DialogProps) {
54 | const router = useRouter()
55 |
56 | function onClose() {
57 | if (returnHref) {
58 | return router.push(returnHref, returnHref, { shallow: false, scroll: false })
59 | }
60 | }
61 |
62 | const showDialog = !!selectedAction
63 |
64 | return (
65 |
74 |
79 | {selectedAction?.fields && (
80 | <>
81 |
82 |
83 | {selectedAction.fields.Name}
84 | {selectedAction.fields.Summary}
85 |
90 | ← Back
91 |
92 |
96 |
97 | >
98 | )}
99 |
100 |
101 | )
102 | }
103 |
104 | export const DEFAULT_ACTION_DIALOG_KEY = 'dialogActionId'
105 |
106 | export function useSelectedAction(solidarityActions: SolidarityAction[], key = DEFAULT_ACTION_DIALOG_KEY) {
107 | const router = useRouter();
108 | const dialogActionId = router.query[key]
109 | const selectedAction = solidarityActions.find(a => a.slug === dialogActionId)
110 | return [selectedAction, key] as const
111 | }
112 |
113 | const DownArrow = (
114 |
115 |
116 |
117 | )
118 |
119 | const UpArrow = (
120 |
121 |
122 |
123 | )
124 |
125 | export function SolidarityActionsList({
126 | data: solidarityActions, withDialog, gridStyle = 'grid-cols-1', dialogProps, mini
127 | }: ListProps) {
128 | const { makeContextualHref } = useContextualRouting();
129 | const [selectedAction, dialogKey] = useSelectedAction(solidarityActions || [], dialogProps?.key)
130 | const [openYears, setOpenYears] = useState([]);
131 |
132 | const actionsByYear = useMemo(() => {
133 | const group = (solidarityActions || []).reduce((bins, action) => {
134 | const key = `${getYear(new Date(action.fields.Date))}`
135 | bins[key] ??= []
136 | bins[key].push(action)
137 | return bins
138 | }, {} as { [key: string]: SolidarityAction[] })
139 |
140 | return Object.entries(group).sort(([year1, d], [year2, D]) => parseInt(year2) - parseInt(year1))
141 | }, [solidarityActions])
142 |
143 | const router = useRouter()
144 |
145 | // when the router changes, update the current route
146 | const [currentHref, setCurrentHref] = useState(router.asPath)
147 |
148 | // store the past route and use it as the currentHref
149 | const lastHref = usePrevious(currentHref)
150 |
151 | useEffect(() => {
152 | const handleRouteChangeComplete = (url, obj) => {
153 | setCurrentHref(url)
154 | }
155 | router.events.on('routeChangeComplete', handleRouteChangeComplete)
156 | return () => router.events.off('routeChangeComplete', handleRouteChangeComplete)
157 | }, [router])
158 |
159 | return (
160 | <>
161 | {withDialog && (
162 |
167 | )}
168 |
169 | {actionsByYear.map(([yearString, actions], i) => {
170 | let hiddenActions = [] as SolidarityAction[]
171 | let shownActions = [] as SolidarityAction[]
172 |
173 | let hasHiddenActions = false;
174 |
175 | if (actions.length > 3) {
176 | hasHiddenActions = true;
177 | shownActions = actions.slice(0, 3)
178 | hiddenActions = actions.slice(3, actions.length)
179 | } else {
180 | shownActions = actions
181 | }
182 |
183 | const hiddenActionsOpen = openYears.includes(yearString);
184 |
185 | const pluralActionsCopy = pluralize('action', hiddenActions.length)
186 |
187 | return (
188 |
189 |
190 |
193 | {yearString}
194 |
195 |
196 | {pluralize('action', actions.length, true)}
197 |
198 |
199 |
200 | {shownActions.map(action =>
201 |
207 |
208 |
209 |
210 |
211 | )}
212 |
213 | {hiddenActions.map(action =>
214 |
220 |
221 |
222 |
223 |
224 | )}
225 |
226 |
227 | {(hasHiddenActions && hiddenActionsOpen === false) && (
228 |
setOpenYears(openYears.concat(openYears, [yearString]))}>
229 | <>
230 | Load {hiddenActions.length} more {pluralActionsCopy}
231 | {DownArrow}
232 | >
233 |
234 | )}
235 | {(hasHiddenActions && hiddenActionsOpen) && (
236 |
setOpenYears(openYears.filter(openYear => openYear !== yearString))}>
237 | <>
238 | Hide {hiddenActions.length} {pluralActionsCopy}
239 | {UpArrow}
240 | >
241 |
242 | )}
243 |
244 | )
245 | })}
246 |
247 | >
248 | )
249 | }
250 |
251 | function groupBy(arr: T[], getGroupKey: (i: T) => string) {
252 | return arr.reduce((groups, i) => {
253 | groups[getGroupKey(i)] ??= []
254 | groups[getGroupKey(i)].push(i)
255 | return groups
256 | }, {} as { [key: string]: T[] })
257 | }
258 |
259 | function getChunks(array: Fuse.FuseResultMatch[]) {
260 | return array.reduce((indicies, d) => {
261 | return indicies.concat(d.indices.map(([start, end]) => ({ start, end: end + 1 })))
262 | }, [] as Chunk[])
263 | }
264 |
265 | function highlightHTML(html: string, search: string, className: string) {
266 | return html.replace(
267 | new RegExp(`(${search})` || '', 'gim'),
268 | `$1 `
269 | )
270 | }
271 |
272 | export function SolidarityActionItem({ data }: { data: SolidarityAction }) {
273 | const { search } = useContext(FilterContext)
274 |
275 | const isFeatured = data.fields.DisplayStyle === 'Featured'
276 |
277 | return (
278 |
279 |
280 |
281 | {isFeatured ? <>
282 |
283 |
289 |
290 | {data.fields.Summary && (
291 |
296 | )}
297 | > :
298 |
299 |
305 | }
306 |
318 |
319 |
320 | )
321 | }
322 |
323 | export function DocumentLink({ filename, filetype, thumbnailURL, thumbnailWidth, thumbnailHeight, originalURL, originalWidth, originalHeight, withPreview }: AirtableCDNMap & {
324 | withPreview?: boolean
325 | }) {
326 | return (
327 |
328 |
329 |
330 |
331 | {filename}
332 |
333 | {filetype}
334 |
335 | {withPreview && (
336 |
337 |
342 |
343 | )}
344 |
345 | )
346 | }
347 |
348 | export function ActionMetadata({ data }: { data: SolidarityAction }) {
349 | return (
350 |
351 |
352 |
353 |
354 | {data.fields.Location ? (
355 | {data.fields.Location}
356 | ) : null}
357 | {data.geography?.country.map((country, i) => (
358 |
359 |
364 | {country.name}
365 |
366 | ))}
367 | {data.fields?.Category?.map((c, i) =>
368 | {data.fields.CategoryEmoji?.[i]} {data.fields.CategoryName?.[i]}
369 | )}
370 |
371 | )
372 | }
373 |
374 | export function SolidarityActionCard({ data, withContext, contextProps }: CardProps) {
375 | const seoTitle = `${format(new Date(data.fields.Date), 'dd MMM yyyy')}: ${data.fields.Name}`
376 |
377 | return (
378 | <>
379 |
396 |
397 |
398 |
401 |
402 |
403 | {data.fields.Name}
404 |
405 | {data.fields.Summary && (
406 |
407 | )}
408 |
417 |
418 | {data.cdnMap?.length > 0 && (
419 |
420 |
Attachments
421 |
422 | {data.cdnMap.map(doc => (
423 |
424 | ))}
425 |
426 |
427 | )}
428 |
431 | {withContext && (
432 |
433 | {data.fields.countryCode?.map(code =>
434 |
435 |
438 |
439 | )}
440 | {data.fields.CategoryName?.map((categoryName, i) =>
441 |
442 | {categoryName}}
446 | />
447 |
448 | )}
449 | {data.fields['Organising Groups']?.map((organisingGroupId, i) =>
450 |
451 | Learn more →}
456 | />
457 |
458 | )}
459 | {data.fields.companyName?.map((companyName, i) =>
460 |
461 | {companyName}}
465 | />
466 |
467 | )}
468 |
469 | )}
470 |
471 | >
472 | )
473 | }
474 |
475 | export function SolidarityActionCountryRelatedActions({ countryCode }: { countryCode }) {
476 | const { data } = useSWR(qs.stringifyUrl({
477 | url: `/api/country`,
478 | query: {
479 | iso2: countryCode
480 | }
481 | }), { revalidateOnMount: true })
482 |
483 | const actionCount = data?.fields?.['Solidarity Actions']?.length || 0
484 |
485 | return (
486 | {data?.fields.Name}
491 | ) : countryCode}
492 | metadata={actionCount ? pluralize('action', actionCount, true) : undefined}
493 | />
494 | )
495 | }
496 |
497 | export function SolidarityActionRelatedActions({ subtitle, url, name, metadata, buttonLabel }: ContextProps) {
498 | return (
499 |
500 |
501 |
502 | {name || 'More actions'}
503 |
504 | {subtitle &&
505 | {subtitle}
506 |
}
507 |
508 | {buttonLabel || {metadata || 'All actions'} → }
509 |
510 |
511 |
512 | )
513 | }
514 |
--------------------------------------------------------------------------------