├── .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 | 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 |
4 | 5 | 6 | 7 | 8 |
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 |
23 | 24 |
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 | 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 | 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 |