├── .env.example ├── .eslintrc.json ├── .gitignore ├── README.md ├── docker-compose.yml ├── next-env.d.ts ├── next.config.js ├── package-lock.json ├── package.json ├── postcss.config.js ├── prisma ├── db.sqlite └── schema.prisma ├── public ├── android-chrome-192x192.png ├── android-chrome-512x512.png ├── apple-touch-icon.png ├── browserconfig.xml ├── favicon-16x16.png ├── favicon-32x32.png ├── favicon.ico ├── favicon.svg ├── mstile-150x150.png ├── safari-pinned-tab.svg └── site.webmanifest ├── src ├── assets │ ├── background.svg │ └── puff.svg ├── components │ ├── auto-animate.tsx │ ├── button.tsx │ ├── card.tsx │ ├── chatbot-walkthrough.tsx │ ├── confirmation-modal.tsx │ ├── dropdown.tsx │ ├── loading.tsx │ ├── modal.tsx │ └── text-input.tsx ├── pages │ ├── _app.tsx │ ├── api │ │ ├── auth │ │ │ └── [...nextauth].ts │ │ ├── external │ │ │ ├── chatbots.tsx │ │ │ └── fossabot.tsx │ │ ├── pusher │ │ │ ├── auth-channel.ts │ │ │ └── auth-user.ts │ │ └── trpc │ │ │ └── [trpc].ts │ ├── ask │ │ └── [username].tsx │ ├── embed │ │ └── [uid].tsx │ └── index.tsx ├── server │ ├── common │ │ ├── get-server-session.ts │ │ └── pusher.ts │ ├── db │ │ └── client.ts │ ├── env-schema.js │ ├── env.js │ └── router │ │ ├── index.ts │ │ ├── subroutes │ │ └── question.ts │ │ ├── trpc │ │ ├── context.ts │ │ └── index.ts │ │ └── utils │ │ └── protected-procedure.ts ├── styles │ └── globals.css ├── types │ └── next-auth.d.ts └── utils │ ├── pusher.tsx │ └── trpc.ts ├── tailwind.config.js ├── tailwind.typography.config.js └── tsconfig.json /.env.example: -------------------------------------------------------------------------------- 1 | DATABASE_URL=postgresql://postgres:postgres@localhost:5432/zapdos 2 | NODE_ENV=development 3 | NEXTAUTH_SECRET=top-secret 4 | NEXTAUTH_URL=http://localhost:3000 5 | TWITCH_CLIENT_ID= 6 | TWITCH_CLIENT_SECRET= 7 | PUSHER_APP_ID=default 8 | NEXT_PUBLIC_PUSHER_APP_KEY=app-key 9 | NEXT_PUBLIC_PUSHER_SERVER_HOST=localhost 10 | NEXT_PUBLIC_PUSHER_SERVER_PORT=6001 11 | NEXT_PUBLIC_PUSHER_SERVER_TLS=false 12 | NEXT_PUBLIC_PUSHER_CLUSTER= 13 | PUSHER_APP_SECRET=app-secret 14 | PUSHER_APP_CLUSTER= -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["next/core-web-vitals"], 3 | "rules": { 4 | "@next/next/no-img-element": "off" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.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 | .pnpm-debug.log* 27 | 28 | # local env files 29 | .env 30 | .env*.local 31 | 32 | # vercel 33 | .vercel 34 | 35 | # typescript 36 | *.tsbuildinfo 37 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Ping Ask - a totally awesome way to share questions in your stream 2 | 3 | Good q&a app. 4 | 5 | 6 | ## Create Your `.env` File 7 | 8 | First you will need to create a `.env` file using the `.env.example` file. 9 | 10 | You will have to add your `TWITCH_CLIENT_ID` and `TWITCH_CLIENT_SECRET` which can be found in the [Twitch Developer Console](https://dev.twitch.tv/console) 11 | 12 | You will need to set the OAuth Redirect Url for you Twitch Application to `http://localhost:3000/api/auth/callback/twitch` 13 | 14 | 15 | ## Run Locally with Docker 16 | 17 | Once you've created the env file run the following command: 18 | 19 | ``` 20 | docker-compose up -d 21 | ``` 22 | 23 | Once the services are up, initialize the db 24 | 25 | ``` 26 | npx prisma db push 27 | ``` 28 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.9" 2 | services: 3 | postgres: 4 | image: postgres:10.5 5 | restart: always 6 | environment: 7 | - POSTGRES_USER=postgres 8 | - POSTGRES_PASSWORD=postgres 9 | - POSTGRES_DB=zapdos 10 | ports: 11 | - "5432:5432" 12 | volumes: 13 | - ./postgres-data:/var/lib/postgresql/data 14 | soketi: 15 | container_name: "soketi_server" 16 | restart: unless-stopped 17 | image: "quay.io/soketi/soketi:0.17-16-alpine" 18 | ports: 19 | - "6001:6001" 20 | - "9601:9601" 21 | environment: 22 | DEBUG: 1 23 | DEFAULT_APP_ID: default 24 | DEFAULT_APP_KEY: app-key 25 | DEFAULT_APP_SECRET: app-secret 26 | networks: 27 | - soketi_network 28 | 29 | networks: 30 | soketi_network: 31 | driver: bridge 32 | -------------------------------------------------------------------------------- /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 | const { env } = require("./src/server/env"); 2 | 3 | /** @type {import('next').NextConfig} */ 4 | const nextConfig = { 5 | reactStrictMode: true, 6 | }; 7 | 8 | const { withPlausibleProxy } = require("next-plausible"); 9 | 10 | module.exports = withPlausibleProxy()(nextConfig); 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "zapdos", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "postinstall": "prisma generate" 11 | }, 12 | "dependencies": { 13 | "@formkit/auto-animate": "^1.0.0-beta.5", 14 | "@headlessui/react": "^1.7.5", 15 | "@next-auth/prisma-adapter": "^1.0.5", 16 | "@prisma/client": "^4.8.0", 17 | "@tailwindcss/typography": "^0.5.8", 18 | "@trpc/client": "^10.0.0-alpha.37", 19 | "@trpc/next": "^10.0.0-alpha.37", 20 | "@trpc/react": "^10.0.0-alpha.37", 21 | "@trpc/server": "^10.0.0-alpha.37", 22 | "clsx": "^1.2.1", 23 | "dayjs": "^1.11.3", 24 | "next": "12.2.5", 25 | "next-auth": "^4.18.7", 26 | "next-plausible": "^3.6.3", 27 | "pusher": "^5.1.1-beta", 28 | "pusher-js": "^7.1.1-beta", 29 | "react": "18.2.0", 30 | "react-dom": "18.2.0", 31 | "react-icons": "^4.4.0", 32 | "react-popper": "^2.3.0", 33 | "react-portal": "^4.2.2", 34 | "react-query": "^3.39.1", 35 | "superjson": "^1.9.1", 36 | "zod": "^3.17.3", 37 | "zustand": "^4.0.0-rc.1" 38 | }, 39 | "devDependencies": { 40 | "@types/node": "^18.0.0", 41 | "@types/pusher-js": "^5.1.0", 42 | "@types/react": "18.0.14", 43 | "@types/react-dom": "18.0.5", 44 | "@types/react-portal": "^4.0.4", 45 | "autoprefixer": "^10.4.7", 46 | "eslint": "8.18.0", 47 | "eslint-config-next": "12.2.1", 48 | "postcss": "^8.4.14", 49 | "prettier": "^2.7.1", 50 | "prettier-plugin-tailwindcss": "^0.1.12", 51 | "prisma": "^4.8.0", 52 | "tailwindcss": "^3.1.6", 53 | "typescript": "4.7.4" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /prisma/db.sqlite: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pingdotgg/PingAsk/4001e32b777ba4888c99e28f5f322a3ceb89585c/prisma/db.sqlite -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | // This is your Prisma schema file, 2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema 3 | 4 | generator client { 5 | provider = "prisma-client-js" 6 | } 7 | 8 | datasource db { 9 | provider = "mysql" 10 | url = env("DATABASE_URL") 11 | relationMode = "prisma" 12 | } 13 | 14 | enum QuestionStatus { 15 | PENDING 16 | PINNED 17 | ANSWERED 18 | } 19 | 20 | model Question { 21 | id String @id @default(cuid()) 22 | body String @db.Text 23 | 24 | userId String 25 | user User @relation(fields: [userId], references: [id]) 26 | 27 | status QuestionStatus @default(PENDING) 28 | 29 | createdAt DateTime @default(now()) 30 | 31 | @@index([userId]) 32 | } 33 | 34 | // Necessary for Next auth 35 | model Account { 36 | id String @id @default(cuid()) 37 | userId String 38 | type String 39 | provider String 40 | providerAccountId String 41 | refresh_token String? @db.Text 42 | access_token String? @db.Text 43 | expires_at Int? 44 | token_type String? 45 | scope String? @db.Text 46 | id_token String? @db.Text 47 | session_state String? @db.Text 48 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 49 | 50 | @@unique([provider, providerAccountId]) 51 | @@index([userId]) 52 | } 53 | 54 | model Session { 55 | id String @id @default(cuid()) 56 | sessionToken String @unique 57 | userId String 58 | expires DateTime 59 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 60 | 61 | @@index([userId]) 62 | } 63 | 64 | model User { 65 | id String @id @default(cuid()) 66 | name String? 67 | email String? @unique 68 | emailVerified DateTime? 69 | image String? 70 | accounts Account[] 71 | sessions Session[] 72 | 73 | Question Question[] 74 | } 75 | 76 | model VerificationToken { 77 | identifier String 78 | token String @unique 79 | expires DateTime 80 | 81 | @@unique([identifier, token]) 82 | } 83 | -------------------------------------------------------------------------------- /public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pingdotgg/PingAsk/4001e32b777ba4888c99e28f5f322a3ceb89585c/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pingdotgg/PingAsk/4001e32b777ba4888c99e28f5f322a3ceb89585c/public/android-chrome-512x512.png -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pingdotgg/PingAsk/4001e32b777ba4888c99e28f5f322a3ceb89585c/public/apple-touch-icon.png -------------------------------------------------------------------------------- /public/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #e24a8d 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pingdotgg/PingAsk/4001e32b777ba4888c99e28f5f322a3ceb89585c/public/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pingdotgg/PingAsk/4001e32b777ba4888c99e28f5f322a3ceb89585c/public/favicon-32x32.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pingdotgg/PingAsk/4001e32b777ba4888c99e28f5f322a3ceb89585c/public/favicon.ico -------------------------------------------------------------------------------- /public/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 11 | 12 | 13 | 14 | 15 | 16 | 18 | 20 | -------------------------------------------------------------------------------- /public/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pingdotgg/PingAsk/4001e32b777ba4888c99e28f5f322a3ceb89585c/public/mstile-150x150.png -------------------------------------------------------------------------------- /public/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | Created by potrace 1.14, written by Peter Selinger 2001-2017 9 | 10 | 12 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /public/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Ping Ask - Q&A for your stream", 3 | "short_name": "Ping Ask", 4 | "icons": [ 5 | { 6 | "src": "/android-chrome-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/android-chrome-512x512.png", 12 | "sizes": "512x512", 13 | "type": "image/png" 14 | } 15 | ], 16 | "theme_color": "#e24a8d", 17 | "background_color": "#e24a8d", 18 | "display": "standalone" 19 | } 20 | -------------------------------------------------------------------------------- /src/assets/background.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 7 | 9 | 11 | 12 | 14 | 16 | 18 | 20 | 22 | 24 | 26 | 27 | 29 | 30 | 32 | 34 | 36 | 37 | 39 | 40 | 42 | 43 | 45 | 47 | 49 | 50 | 52 | 54 | 56 | 58 | 60 | 61 | 63 | 65 | 67 | 69 | 71 | 72 | 74 | 76 | 78 | 80 | 82 | 83 | 85 | 86 | 88 | 89 | 91 | 92 | 94 | 96 | 98 | 99 | 101 | 103 | 105 | 107 | 109 | 111 | 113 | 114 | 116 | 117 | 119 | 121 | 123 | 125 | 127 | 129 | 131 | 133 | 135 | 136 | 138 | 139 | 141 | 142 | 144 | 146 | 148 | 149 | 151 | 152 | 154 | 156 | 158 | 160 | 162 | 163 | 165 | 166 | -------------------------------------------------------------------------------- /src/assets/puff.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 12 | 19 | 20 | 21 | 28 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /src/components/auto-animate.tsx: -------------------------------------------------------------------------------- 1 | import { ElementType, HTMLAttributes } from "react"; 2 | import { useAutoAnimate } from "@formkit/auto-animate/react"; 3 | 4 | interface Props extends HTMLAttributes { 5 | as?: ElementType; 6 | } 7 | 8 | export const AutoAnimate: React.FC = ({ 9 | as: Tag = "div", 10 | children, 11 | ...rest 12 | }) => { 13 | const [ref] = useAutoAnimate(); 14 | return ( 15 | 16 | {children} 17 | 18 | ); 19 | }; 20 | -------------------------------------------------------------------------------- /src/components/button.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx"; 2 | import React, { ReactElement } from "react"; 3 | import { LoadingSpinner } from "./loading"; 4 | 5 | export type HTMLButtonProps = React.DetailedHTMLProps< 6 | React.ButtonHTMLAttributes, 7 | HTMLButtonElement 8 | >; 9 | 10 | export type HTMLAnchorProps = React.DetailedHTMLProps< 11 | React.AnchorHTMLAttributes, 12 | HTMLAnchorElement 13 | >; 14 | 15 | export const BUTTON_CLASSES = 16 | "inline-flex items-center border font-medium relative"; 17 | 18 | export type ButtonVariant = 19 | | "primary" 20 | | "primary-inverted" 21 | | "secondary" 22 | | "secondary-inverted" 23 | | "ghost" 24 | | "danger" 25 | | "text"; 26 | 27 | type ButtonSize = "xs" | "sm" | "base" | "lg" | "xl" | "2xl"; 28 | 29 | type ButtonIconPosition = "start" | "end"; 30 | 31 | type ButtonStyle = { 32 | disabled?: boolean; 33 | size?: ButtonSize; 34 | variant?: ButtonVariant; 35 | }; 36 | 37 | export type ButtonProps = { 38 | icon?: ReactElement; 39 | iconPosition?: ButtonIconPosition; 40 | loading?: boolean; 41 | } & ButtonStyle; 42 | 43 | export const BUTTON_SIZES = { 44 | xs: "text-xs px-2.5 py-1.5 rounded", 45 | sm: "text-sm px-3 py-2 leading-4 rounded", 46 | base: "text-sm px-4 py-2 rounded", 47 | lg: "text-base px-4 py-2 rounded-md", 48 | xl: "text-lg px-6 py-3 rounded-md", 49 | "2xl": "text-xl px-8 py-3 md:py-4 md:text-2xl md:px-8 rounded-lg", 50 | }; 51 | 52 | export const ICON_SIZE_CLASSES = { 53 | xs: "h-4 w-4", 54 | sm: "h-4 w-4", 55 | base: "h-5 w-5", 56 | lg: "h-5 w-5", 57 | xl: "h-5 w-5", 58 | "2xl": "h-6 w-6", 59 | }; 60 | export const ICON_START_CLASSES = { 61 | xs: "-ml-0.5 mr-1.5", 62 | sm: "-ml-0.5 mr-1.5", 63 | base: "-ml-1 mr-1.5", 64 | lg: "-ml-1 mr-2", 65 | xl: "-ml-1 mr-2", 66 | "2xl": "-ml-1 mr-2", 67 | }; 68 | export const ICON_END_CLASSES = { 69 | xs: "-mr-0.5 ml-1.5", 70 | sm: "-mr-0.5 ml-1.5", 71 | base: "-mr-1 ml-1.5", 72 | lg: "-mr-1 ml-2", 73 | xl: "-mr-1 ml-2", 74 | "2xl": "-mr-1 ml-2", 75 | }; 76 | 77 | export const BUTTON_VARIANTS = { 78 | primary: 79 | "text-white border-pink-700 bg-pink-600 hover:bg-pink-700 hover:border-pink-800 shadow-sm", 80 | "primary-inverted": 81 | "text-pink-600 border-transparent bg-white hover:bg-pink-50 shadow-sm", 82 | secondary: 83 | "text-white border-gray-700 bg-gray-800 hover:bg-gray-750 hover:text-gray-100 shadow-sm", 84 | "secondary-inverted": 85 | "text-gray-900 border-transparent bg-gray-100 hover:bg-gray-200 shadow-sm", 86 | ghost: "text-white border-transparent hover:bg-gray-750 hover:text-gray-100", 87 | danger: 88 | "text-white border-red-700 bg-red-600 hover:bg-red-700 hover:border-red-800", 89 | text: "text-white border-transparent hover:text-gray-300", 90 | }; 91 | 92 | export const getButtonClasses = ( 93 | style: ButtonStyle = {}, 94 | ...rest: string[] 95 | ) => { 96 | const { disabled, size = "base", variant = "secondary" } = style; 97 | return clsx( 98 | BUTTON_CLASSES, 99 | disabled && "pointer-events-none", 100 | BUTTON_SIZES[size], 101 | BUTTON_VARIANTS[variant], 102 | ...rest 103 | ); 104 | }; 105 | 106 | const ButtonContent: React.FC<{ 107 | loading?: boolean; 108 | size?: ButtonSize; 109 | icon?: ReactElement; 110 | iconPosition?: ButtonIconPosition; 111 | children?: React.ReactNode; 112 | }> = ({ loading, icon, iconPosition = "start", size = "base", children }) => { 113 | return ( 114 | 115 | {loading && ( 116 | 117 | 118 | 119 | )} 120 | {icon && iconPosition === "start" && ( 121 | 128 | {icon} 129 | 130 | )} 131 | {children} 132 | {icon && iconPosition === "end" && ( 133 | 140 | {icon} 141 | 142 | )} 143 | 144 | ); 145 | }; 146 | 147 | /** 148 | * Button component that renders an `` element 149 | * 150 | * Wrap with next.js `` for client side routing 151 | */ 152 | export const ButtonLink = React.forwardRef< 153 | HTMLAnchorElement, 154 | ButtonProps & HTMLAnchorProps 155 | >((props, ref) => { 156 | const { 157 | children, 158 | className = "", 159 | disabled, 160 | size, 161 | variant, 162 | icon, 163 | iconPosition, 164 | loading, 165 | ...rest 166 | } = props; 167 | return ( 168 | 174 | 175 | 176 | ); 177 | }); 178 | ButtonLink.displayName = "ButtonLink"; 179 | 180 | /** 181 | * Button component that renders a ` 210 | ); 211 | }); 212 | 213 | Button.displayName = "Button"; 214 | 215 | export default Button; 216 | -------------------------------------------------------------------------------- /src/components/card.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx"; 2 | import { HTMLAttributes } from "react"; 3 | 4 | export const Card: React.FC< 5 | { 6 | className?: string; 7 | } & HTMLAttributes 8 | > = ({ className, ...rest }) => { 9 | return ( 10 |
17 | ); 18 | }; 19 | -------------------------------------------------------------------------------- /src/components/chatbot-walkthrough.tsx: -------------------------------------------------------------------------------- 1 | import { Tab } from "@headlessui/react"; 2 | import clsx from "clsx"; 3 | import React from "react"; 4 | import Button from "./button"; 5 | 6 | const TABS = [ 7 | { 8 | label: "Fossabot", 9 | content: ( 10 | <> 11 |

12 | To connect Fossabot, create a new command with the following{" "} 13 | Response and set the Response Type{" "} 14 | to Reply. 15 |

16 |
{`$(customapi https://ask.ping.gg/api/external/fossabot)`}
17 |

18 | Messages sent to this command on your channel will automagically be 19 | added to your questions ✨ 20 |

21 | 22 | ), 23 | }, 24 | { 25 | label: "Nightbot", 26 | content: ( 27 | <> 28 |

29 | To connect Nightbot, create a new command with the following{" "} 30 | Message. 31 |

32 |
{`@$(user) $(urlfetch https://ask.ping.gg/api/external/chatbots?q=$(querystring)&channel=$(channel)&user=$(user))`}
33 |

34 | Messages sent to this command on your channel will automagically be 35 | added to your questions ✨ 36 |

37 | 38 | ), 39 | }, 40 | { 41 | label: "StreamElements", 42 | content: ( 43 | <> 44 |

45 | To connect StreamElements, create a new command with the following{" "} 46 | Response. 47 |

48 |
 49 |           {
 50 |             "@${user} ${urlfetch https://ask.ping.gg/api/external/chatbots?channel=${channel}&q=${queryescape ${1:}}&user=${user}}"
 51 |           }
 52 |         
53 |

54 | Messages sent to this command on your channel will automagically be 55 | added to your questions ✨ 56 |

57 | 58 | ), 59 | }, 60 | { 61 | label: "Other", 62 | content: ( 63 | <> 64 |

65 | If your chatbot supports it, you can try configuring it to make an 66 | HTTP GET request to ask.ping.gg/api/external/chatbots{" "} 67 | with the following query parameters. 68 |

69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 81 | 82 | 83 | 84 | 87 | 90 | 91 | 92 | 95 | 96 | 97 | 98 |
ParameterDescription
79 | q 80 | The question content to be submitted.
85 | channel 86 | 88 | The Twitch channel (username) where the question is being asked. 89 |
93 | user 94 | The username of the Twitch user submitting the question.
99 |

100 | If you're having trouble configuring your chatbot, hit us up on{" "} 101 | 102 | Discord 103 | {" "} 104 | and we'll do our best to help you get set up 🚀 105 |

106 | 107 | ), 108 | }, 109 | ]; 110 | 111 | export const ChatbotWalkthrough: React.FC = () => { 112 | return ( 113 |
114 |
115 |

116 | Connecting a chatbot allows your viewers to ask questions by typing a 117 | command directly in your Twitch chat. For example, 118 | {"!ask How do magnets work?"}. 119 |

120 |

121 | Ping Ask officially supports{" "} 122 | 127 | Fossabot 128 | 129 | ,{" "} 130 | 135 | Nightbot 136 | 137 | , and{" "} 138 | 143 | StreamElements 144 | 145 | . 146 |

147 |
148 | 149 | 150 | {TABS.map(({ label }) => ( 151 | 152 | {({ selected }) => ( 153 | 163 | )} 164 | 165 | ))} 166 | 167 | 168 | {TABS.map(({ label, content }) => ( 169 | 170 | {content} 171 | 172 | ))} 173 | 174 | 175 |
176 | ); 177 | }; 178 | -------------------------------------------------------------------------------- /src/components/confirmation-modal.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx"; 2 | import React, { useEffect, useRef } from "react"; 3 | import create from "zustand"; 4 | import Button from "./button"; 5 | import { Modal } from "./modal"; 6 | 7 | interface ModalStoreState { 8 | content?: JSX.Element; 9 | setContent: (content?: JSX.Element) => void; 10 | } 11 | 12 | const useModalStore = create((set) => ({ 13 | content: undefined, 14 | setContent: (content) => set({ content }), 15 | })); 16 | 17 | export const useConfirmationModal = (options: ConfirmationModalOptions) => { 18 | const setContent = useModalStore((s) => s.setContent); 19 | const { onConfirm, ...rest } = options; 20 | const trigger = () => { 21 | setContent( 22 | { 24 | onConfirm?.(); 25 | setContent(undefined); 26 | }} 27 | onCancel={() => setContent(undefined)} 28 | {...rest} 29 | /> 30 | ); 31 | }; 32 | return trigger; 33 | }; 34 | 35 | export const ModalContainer: React.FC = () => { 36 | const [content, setContent] = useModalStore((s) => [s.content, s.setContent]); 37 | return ( 38 | !open && setContent(undefined)]}> 39 | {content} 40 | 41 | ); 42 | }; 43 | 44 | type ConfirmationModalOptions = { 45 | title: string; 46 | description: string; 47 | confirmationLabel?: string; 48 | onConfirm?: () => void; 49 | icon?: React.ReactNode; 50 | variant?: "primary" | "danger"; 51 | }; 52 | 53 | const ConfirmationModal: React.FC< 54 | ConfirmationModalOptions & { 55 | onCancel?: () => void; 56 | } 57 | > = ({ 58 | title, 59 | description, 60 | confirmationLabel = "Okay", 61 | onConfirm, 62 | onCancel, 63 | icon, 64 | variant = "primary", 65 | }) => { 66 | const cancelButtonRef = useRef(null); 67 | useEffect(() => { 68 | if (!cancelButtonRef.current) return; 69 | cancelButtonRef.current.focus(); 70 | }, []); 71 | return ( 72 |
73 |
74 | {icon && ( 75 |
84 | {icon} 85 |
86 | )} 87 |
88 | 89 | {title} 90 | 91 |
92 |

{description}

93 |
94 |
95 |
96 |
97 | 104 | 111 |
112 |
113 | ); 114 | }; 115 | -------------------------------------------------------------------------------- /src/components/dropdown.tsx: -------------------------------------------------------------------------------- 1 | import React, { Fragment, useState } from "react"; 2 | import NextLink from "next/link"; 3 | import { Menu, Transition } from "@headlessui/react"; 4 | import { usePopper } from "react-popper"; 5 | import { Portal } from "react-portal"; 6 | import classNames from "clsx"; 7 | 8 | import type { ReactElement } from "react"; 9 | import type { LinkProps } from "next/link"; 10 | import type { Placement } from "@popperjs/core"; 11 | 12 | export const POPPER_PLACEMENT_ORIGIN = { 13 | auto: "", 14 | "auto-start": "", 15 | "auto-end": "", 16 | top: "bottom", 17 | "top-start": "bottom left", 18 | "top-end": "bottom right", 19 | bottom: "top", 20 | "bottom-start": "top left", 21 | "bottom-end": "top right", 22 | right: "left", 23 | "right-start": "top left", 24 | "right-end": "bottom left", 25 | left: "right", 26 | "left-start": "top right", 27 | "left-end": "bottom right", 28 | }; 29 | 30 | type DropdownItemCommon = { 31 | label: string | JSX.Element; 32 | }; 33 | 34 | type DropdownItemButton = DropdownItemCommon & { 35 | onClick: React.MouseEventHandler; 36 | disabled?: boolean; 37 | }; 38 | 39 | type DropdownItemLink = DropdownItemCommon & { 40 | href?: string; 41 | }; 42 | 43 | type DropdownItem = DropdownItemButton | DropdownItemLink; 44 | 45 | export type DropdownItems = 46 | | DropdownItem[] 47 | | DropdownItemWithIcon[] 48 | | GroupedDropdownItems[] 49 | | GroupedDropdownItemsWithIcon[]; 50 | 51 | type GroupedDropdownItems = { 52 | label: string; 53 | items: DropdownItem[]; 54 | }; 55 | 56 | type GroupedDropdownItemsWithIcon = { 57 | label: string; 58 | items: DropdownItemWithIcon[]; 59 | }; 60 | 61 | function isGrouped(items: DropdownItems): items is GroupedDropdownItems[] { 62 | const group = (items as GroupedDropdownItems[])[0]; 63 | return group?.items !== undefined; 64 | } 65 | 66 | function isButton( 67 | item: DropdownItemButton | DropdownItemLink 68 | ): item is DropdownItemButton { 69 | return (item as DropdownItemButton).onClick !== undefined; 70 | } 71 | 72 | type DropdownItemWithIcon = { 73 | icon: ReactElement; 74 | } & DropdownItem; 75 | 76 | function hasIcon( 77 | item: DropdownItemWithIcon | DropdownItem 78 | ): item is DropdownItemWithIcon { 79 | return (item as DropdownItemWithIcon).icon !== undefined; 80 | } 81 | 82 | type PassthroughLinkProps = LinkProps & 83 | React.AnchorHTMLAttributes; 84 | 85 | const PassthroughLink: React.FC = (props) => { 86 | let { href, children, ...rest } = props; 87 | return ( 88 | 89 | {children} 90 | 91 | ); 92 | }; 93 | 94 | const Dropdown: React.FC<{ 95 | trigger: React.ReactNode; 96 | placement?: Placement; 97 | items: DropdownItems; 98 | className?: string; 99 | noPortal?: Boolean; 100 | }> = (props) => { 101 | const { 102 | placement = "top-start", 103 | trigger, 104 | items, 105 | className, 106 | noPortal, 107 | } = props; 108 | const [referenceElement, setReferenceElement] = 109 | useState(null); 110 | const [popperElement, setPopperElement] = useState( 111 | null 112 | ); 113 | const { styles, attributes, update, state } = usePopper( 114 | referenceElement, 115 | popperElement, 116 | { 117 | placement, 118 | modifiers: [ 119 | { 120 | name: "offset", 121 | options: { 122 | offset: [0, 8], 123 | }, 124 | }, 125 | ], 126 | } 127 | ); 128 | return ( 129 | 130 |
131 | {trigger} 132 |
133 | 134 |
139 | update?.()} 147 | > 148 |
149 | 156 | {isGrouped(items) ? ( 157 | items.map((group) => ( 158 |
159 |
160 | {group.label} 161 |
162 | 163 |
164 | )) 165 | ) : ( 166 | 167 | )} 168 |
169 |
170 |
171 |
172 |
173 |
174 | ); 175 | }; 176 | 177 | export const DropdownItems: React.FC<{ 178 | items: DropdownItem[] | DropdownItemWithIcon[]; 179 | }> = ({ items }) => { 180 | return ( 181 | <> 182 | {items.map((item) => { 183 | const renderedIcon = hasIcon(item) && ( 184 | 185 | {item.icon} 186 | 187 | ); 188 | 189 | const { label } = item; 190 | const commonClasses = 191 | "group flex items-center w-full px-4 py-2 text-sm first:pt-3 last:pb-3"; 192 | 193 | if (isButton(item)) { 194 | return ( 195 | 196 | {({ active }) => ( 197 | 211 | )} 212 | 213 | ); 214 | } 215 | 216 | return ( 217 | 218 | {({ active }) => 219 | item.href ? ( 220 | 227 | {renderedIcon} 228 | {label} 229 | 230 | ) : ( 231 | 237 | {renderedIcon} 238 | {label} 239 | 240 | ) 241 | } 242 | 243 | ); 244 | })} 245 | 246 | ); 247 | }; 248 | 249 | export default Dropdown; 250 | -------------------------------------------------------------------------------- /src/components/loading.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx"; 2 | 3 | export const LoadingSpinner: React.FC<{ 4 | className?: string; 5 | }> = ({ className }) => { 6 | return ( 7 | 13 | 21 | 26 | 27 | ); 28 | }; 29 | -------------------------------------------------------------------------------- /src/components/modal.tsx: -------------------------------------------------------------------------------- 1 | import { Fragment } from "react"; 2 | import { Dialog, Transition } from "@headlessui/react"; 3 | 4 | import type { Dispatch, MutableRefObject, SetStateAction } from "react"; 5 | 6 | export const Modal: React.FC<{ 7 | openState: [boolean, Dispatch>]; 8 | initialFocus?: MutableRefObject; 9 | children: React.ReactNode; 10 | }> & { 11 | Title: typeof Dialog.Title; 12 | Description: typeof Dialog.Description; 13 | } = ({ openState, initialFocus, children }) => { 14 | const [open, setOpen] = openState; 15 | 16 | return ( 17 | 18 | 24 |
25 | 34 | 35 | 36 | 37 | {/* This element is to trick the browser into centering the modal contents. */} 38 | 44 | 53 |
54 | {children} 55 |
56 |
57 |
58 |
59 |
60 | ); 61 | }; 62 | 63 | Modal.Title = Dialog.Title; 64 | Modal.Description = Dialog.Description; 65 | -------------------------------------------------------------------------------- /src/components/text-input.tsx: -------------------------------------------------------------------------------- 1 | import classNames from "clsx"; 2 | import React, { ReactElement } from "react"; 3 | 4 | export type InputProps = React.DetailedHTMLProps< 5 | React.InputHTMLAttributes, 6 | HTMLInputElement 7 | >; 8 | 9 | export const TextInput = React.forwardRef< 10 | HTMLInputElement, 11 | { 12 | prefixEl?: ReactElement | string; 13 | suffixEl?: ReactElement | string; 14 | className?: string; 15 | } & InputProps 16 | >((props, ref) => { 17 | const { prefixEl, suffixEl, className, ...rest } = props; 18 | return ( 19 |
25 | {prefixEl && ( 26 |
27 | {prefixEl} 28 |
29 | )} 30 | 36 | {suffixEl && ( 37 |
38 | {suffixEl} 39 |
40 | )} 41 |
42 | ); 43 | }); 44 | 45 | TextInput.displayName = "TextInput"; 46 | -------------------------------------------------------------------------------- /src/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | // src/pages/_app.tsx 2 | import { withTRPC } from "@trpc/next"; 3 | import type { AppRouter } from "../server/router"; 4 | import type { AppType } from "next/dist/shared/lib/utils"; 5 | import superjson from "superjson"; 6 | import { SessionProvider } from "next-auth/react"; 7 | import "../styles/globals.css"; 8 | import PlausibleProvider from "next-plausible"; 9 | import { ModalContainer } from "../components/confirmation-modal"; 10 | 11 | const MyApp: AppType = ({ 12 | Component, 13 | pageProps: { session, ...pageProps }, 14 | }) => { 15 | return ( 16 | 17 | 18 | 19 | 20 | 21 | 22 | ); 23 | }; 24 | 25 | const getBaseUrl = () => { 26 | if (typeof window !== "undefined") { 27 | return ""; 28 | } 29 | if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`; // SSR should use vercel url 30 | 31 | return `http://localhost:${process.env.PORT ?? 3000}`; // dev SSR should use localhost 32 | }; 33 | 34 | export default withTRPC({ 35 | config({ ctx }) { 36 | /** 37 | * If you want to use SSR, you need to use the server's full URL 38 | * @link https://trpc.io/docs/ssr 39 | */ 40 | const url = `${getBaseUrl()}/api/trpc`; 41 | 42 | return { 43 | url, 44 | transformer: superjson, 45 | /** 46 | * @link https://react-query.tanstack.com/reference/QueryClient 47 | */ 48 | // queryClientConfig: { defaultOptions: { queries: { staleTime: 60 } } }, 49 | }; 50 | }, 51 | /** 52 | * @link https://trpc.io/docs/ssr 53 | */ 54 | ssr: false, 55 | })(MyApp); 56 | -------------------------------------------------------------------------------- /src/pages/api/auth/[...nextauth].ts: -------------------------------------------------------------------------------- 1 | import NextAuth, { NextAuthOptions } from "next-auth"; 2 | import TwitchProvider from "next-auth/providers/twitch"; 3 | 4 | // Prisma adapter for NextAuth, optional and can be removed 5 | import { PrismaAdapter } from "@next-auth/prisma-adapter"; 6 | import { prisma } from "../../../server/db/client"; 7 | import { env } from "../../../server/env"; 8 | 9 | export const authOptions: NextAuthOptions = { 10 | // Configure one or more authentication providers 11 | adapter: PrismaAdapter(prisma), 12 | providers: [ 13 | TwitchProvider({ 14 | clientId: env.TWITCH_CLIENT_ID, 15 | clientSecret: env.TWITCH_CLIENT_SECRET, 16 | }), 17 | ], 18 | callbacks: { 19 | session({ session, user }) { 20 | if (session.user) { 21 | session.user.id = user.id; 22 | } 23 | return session; 24 | }, 25 | }, 26 | events: { 27 | async signIn(message) { 28 | const { user, account, profile, isNewUser } = message; 29 | 30 | // If user is new, notify on discord. Don't run if webhook env var is not set 31 | if (isNewUser && typeof env.DISCORD_NEW_USER_WEBHOOK === "string") { 32 | const socialLink = () => { 33 | if (account?.provider === "twitch") 34 | return `[${profile?.name} (${account.provider})](https://twitch.tv/${profile?.name})`; 35 | if (account?.provider === "twitter") 36 | return `[${profile?.name} (${account.provider})](https://twitter.com/${profile?.name})`; 37 | return `${profile?.name} (${account?.provider})`; 38 | }; 39 | 40 | const content = `${socialLink()} just signed in for the first time!`; 41 | 42 | fetch(env.DISCORD_NEW_USER_WEBHOOK, { 43 | method: "POST", 44 | body: JSON.stringify({ 45 | content, 46 | }), 47 | headers: { 48 | "Content-Type": "application/json", 49 | }, 50 | }); 51 | } 52 | 53 | // Updates user record with latest image 54 | if (user.id) { 55 | await prisma.user.update({ 56 | where: { 57 | id: user.id as string, 58 | }, 59 | data: { 60 | image: profile?.image, 61 | }, 62 | }); 63 | } 64 | }, 65 | }, 66 | }; 67 | 68 | export default NextAuth(authOptions); 69 | -------------------------------------------------------------------------------- /src/pages/api/external/chatbots.tsx: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from "next"; 2 | import { pusherServerClient } from "../../../server/common/pusher"; 3 | import { prisma } from "../../../server/db/client"; 4 | import { PREFIX } from "./fossabot"; 5 | 6 | const handleRequest = async (req: NextApiRequest, res: NextApiResponse) => { 7 | const channelName = req.query["channel"] as string; 8 | const question = req.query["q"] as string; 9 | // const askerName = req.query["user"] as string; 10 | if (!question) { 11 | res 12 | .status(200) 13 | .send( 14 | `${PREFIX}No question provided NotLikeThis Make sure you include a question after the command.` 15 | ); 16 | return; 17 | } 18 | 19 | if (!channelName) { 20 | res.status(200).send(`${PREFIX}Channel name missing, check your bot configuration.`); 21 | return; 22 | } 23 | 24 | //find user in database 25 | const user = await prisma.user.findFirst({ 26 | where: { name: { equals: channelName } }, 27 | }); 28 | 29 | if (!user) { 30 | res 31 | .status(200) 32 | .send( 33 | `${PREFIX}Channel ${channelName} not found, does it match your Ping Ask account?` 34 | ); 35 | return; 36 | } 37 | 38 | // insert question into database 39 | await prisma.question.create({ 40 | data: { 41 | body: question, 42 | userId: user.id, 43 | }, 44 | }); 45 | 46 | // inform client of new question 47 | await pusherServerClient.trigger(`user-${user.id}`, "new-question", {}); 48 | 49 | res.status(200).send(`${PREFIX}Question Added! SeemsGood`); 50 | }; 51 | 52 | export default handleRequest; 53 | -------------------------------------------------------------------------------- /src/pages/api/external/fossabot.tsx: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from "next"; 2 | import { pusherServerClient } from "../../../server/common/pusher"; 3 | import { prisma } from "../../../server/db/client"; 4 | 5 | export const PREFIX = "[Ping Ask] " 6 | 7 | const handleRequest = async (req: NextApiRequest, res: NextApiResponse) => { 8 | const validateUrl = req.headers["x-fossabot-validateurl"] as string; 9 | const channelName = req.headers["x-fossabot-channeldisplayname"] as string; 10 | 11 | try { 12 | if (!validateUrl || !channelName) { 13 | res.status(400).send(`${PREFIX}Invalid request`); 14 | return; 15 | } 16 | 17 | //find user in database 18 | const user = await prisma.user.findFirst({ 19 | where: { name: { equals: channelName } }, 20 | }); 21 | 22 | if (!user) { 23 | res.status(400).send(`${PREFIX}User not found`); 24 | return; 25 | } 26 | 27 | //validate request is coming from fossabot 28 | const validateResponse = await fetch(validateUrl); 29 | 30 | if (validateResponse.status !== 200) { 31 | res.status(400).send(`${PREFIX}Failed to validate request.`); 32 | return; 33 | } 34 | 35 | const messageDataUrl = await validateResponse 36 | .json() 37 | .then((data) => data.context_url); 38 | 39 | const messageDataResponse = await fetch(messageDataUrl); 40 | 41 | if (messageDataResponse.status !== 200) { 42 | res.status(400).send(`${PREFIX}Failed to fetch message data`); 43 | return; 44 | } 45 | 46 | const messageData = await messageDataResponse.json(); 47 | 48 | // strip off the command, e.g. !ask 49 | const [command, ...rest] = messageData.message.content?.split(" "); 50 | const question = rest.join(" "); 51 | 52 | if (!question || question.trim() === "") { 53 | res 54 | .status(400) 55 | .send( 56 | `${PREFIX}No question provided NotLikeThis Try "${command} How do magnets work?"` 57 | ); 58 | return; 59 | } 60 | 61 | // insert question into database 62 | await prisma.question.create({ 63 | data: { 64 | body: question, 65 | userId: user.id, 66 | }, 67 | }); 68 | 69 | // inform client of new question 70 | await pusherServerClient.trigger(`user-${user.id}`, "new-question", {}); 71 | 72 | res.status(200).send(`${PREFIX}Question Added! SeemsGood`); 73 | } catch (e) { 74 | console.log(e); 75 | res.status(500).send(`${PREFIX}Internal Server Error`); 76 | } 77 | }; 78 | 79 | export default handleRequest; 80 | -------------------------------------------------------------------------------- /src/pages/api/pusher/auth-channel.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from "next"; 2 | import { pusherServerClient } from "../../../server/common/pusher"; 3 | 4 | export default function pusherAuthEndpoint( 5 | req: NextApiRequest, 6 | res: NextApiResponse 7 | ) { 8 | const { channel_name, socket_id } = req.body; 9 | const { user_id } = req.headers; 10 | 11 | if (!user_id || typeof user_id !== "string") { 12 | res.status(404).send("lol"); 13 | return; 14 | } 15 | const auth = pusherServerClient.authorizeChannel(socket_id, channel_name, { 16 | user_id, 17 | user_info: { 18 | name: "oaiwmeroauwhero;aijhwer", 19 | }, 20 | }); 21 | res.send(auth); 22 | } 23 | -------------------------------------------------------------------------------- /src/pages/api/pusher/auth-user.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from "next"; 2 | import { pusherServerClient } from "../../../server/common/pusher"; 3 | 4 | export default function pusherAuthUserEndpoint( 5 | req: NextApiRequest, 6 | res: NextApiResponse 7 | ) { 8 | const { socket_id } = req.body; 9 | const { user_id } = req.headers; 10 | 11 | if (!user_id || typeof user_id !== "string") { 12 | res.status(404).send("lol"); 13 | return; 14 | } 15 | const auth = pusherServerClient.authenticateUser(socket_id, { 16 | id: user_id, 17 | name: "theo", 18 | }); 19 | res.send(auth); 20 | } 21 | -------------------------------------------------------------------------------- /src/pages/api/trpc/[trpc].ts: -------------------------------------------------------------------------------- 1 | import { createNextApiHandler } from "@trpc/server/adapters/next"; 2 | import { appRouter } from "../../../server/router"; 3 | import { createContext } from "../../../server/router/trpc/context"; 4 | 5 | export default createNextApiHandler({ 6 | router: appRouter, 7 | createContext: createContext, 8 | }); 9 | -------------------------------------------------------------------------------- /src/pages/ask/[username].tsx: -------------------------------------------------------------------------------- 1 | import { GetStaticProps } from "next"; 2 | import Head from "next/head"; 3 | import { useState } from "react"; 4 | import { trpc } from "../../utils/trpc"; 5 | import { prisma } from "../../server/db/client"; 6 | import type { User } from "@prisma/client"; 7 | import clsx from "clsx"; 8 | import { LoadingSpinner } from "../../components/loading"; 9 | import Button from "../../components/button"; 10 | import { TextInput } from "../../components/text-input"; 11 | 12 | const AskForm = (props: { user: User }) => { 13 | if (!props.user) throw new Error("user exists Next, sorry"); 14 | const { mutate, isLoading, isSuccess, reset } = 15 | trpc.proxy.questions.submit.useMutation(); 16 | const [question, setQuestion] = useState(""); 17 | const handleSubmit = () => { 18 | if (!question) return; 19 | mutate({ userId: props.user.id, question }); 20 | setQuestion(""); 21 | }; 22 | 23 | return ( 24 | <> 25 | 26 | {`Ask ${props.user?.name} a question!`} 27 | 28 |
29 |
30 | {props.user.image && ( 31 | Profile picture 36 | )} 37 |

38 | Ask {props.user?.name} a question! 39 |

40 | {!isSuccess && ( 41 | <> 42 | setQuestion(e.target.value)} 49 | onKeyDown={(e) => { 50 | if (e.key === "Enter") handleSubmit(); 51 | }} 52 | /> 53 | 62 | 63 | )} 64 | {isSuccess && ( 65 | <> 66 |
67 | Question submitted! 68 |
69 | 70 | 73 | 74 | )} 75 |
76 |
77 | 78 | ); 79 | }; 80 | 81 | export const getStaticProps: GetStaticProps = async ({ params }) => { 82 | if (!params || !params.username || typeof params.username !== "string") { 83 | return { 84 | notFound: true, 85 | revalidate: 60, 86 | }; 87 | } 88 | const twitchName = params.username.toLowerCase(); 89 | 90 | const userInfo = await prisma.user.findFirst({ 91 | where: { name: { equals: twitchName } }, 92 | }); 93 | 94 | if (!userInfo) { 95 | return { 96 | notFound: true, 97 | revalidate: 60, 98 | }; 99 | } 100 | 101 | return { props: { user: userInfo }, revalidate: 60 }; 102 | }; 103 | 104 | export async function getStaticPaths() { 105 | return { paths: [], fallback: "blocking" }; 106 | } 107 | 108 | export default AskForm; 109 | -------------------------------------------------------------------------------- /src/pages/embed/[uid].tsx: -------------------------------------------------------------------------------- 1 | import dynamic from "next/dynamic"; 2 | import type { 3 | GetServerSidePropsContext, 4 | InferGetServerSidePropsType, 5 | } from "next/types"; 6 | 7 | import React, { useState } from "react"; 8 | import { PusherProvider, useSubscribeToEvent } from "../../utils/pusher"; 9 | import { prisma } from "../../server/db/client"; 10 | 11 | type ServerSideProps = InferGetServerSidePropsType; 12 | 13 | const useLatestPusherMessage = (initialPinnedQuestion: string | null) => { 14 | const [latestMessage, setLatestMessage] = useState( 15 | initialPinnedQuestion 16 | ); 17 | 18 | useSubscribeToEvent("question-pinned", (data: { question: string }) => 19 | setLatestMessage(data.question) 20 | ); 21 | useSubscribeToEvent("question-unpinned", () => setLatestMessage(null)); 22 | 23 | return latestMessage; 24 | }; 25 | 26 | const BrowserEmbedViewCore: React.FC = ({ 27 | pinnedQuestion, 28 | }) => { 29 | const latestMessage = useLatestPusherMessage(pinnedQuestion ?? null); 30 | 31 | if (!latestMessage) return null; 32 | 33 | return ( 34 |
35 |
39 | {latestMessage} 40 |
41 |
42 | ); 43 | }; 44 | 45 | const LazyEmbedView = dynamic(() => Promise.resolve(BrowserEmbedViewCore), { 46 | ssr: false, 47 | }); 48 | 49 | const BrowserEmbedView: React.FC = (props) => { 50 | if (!props.userId) return null; 51 | 52 | return ( 53 | 54 | 55 | 56 | ); 57 | }; 58 | 59 | export default BrowserEmbedView; 60 | 61 | export const getServerSideProps = async ( 62 | context: GetServerSidePropsContext 63 | ) => { 64 | const uid = context.query.uid; 65 | 66 | if (typeof uid !== "string") return { props: { success: false } }; 67 | 68 | const pinnedQuestion = await prisma.question 69 | .findFirst({ 70 | where: { userId: uid, status: "PINNED" }, 71 | }) 72 | .then((question) => question?.body); 73 | 74 | return { 75 | props: { 76 | userId: uid, 77 | pinnedQuestion: pinnedQuestion ?? null, 78 | }, 79 | }; 80 | }; 81 | -------------------------------------------------------------------------------- /src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import type { GetServerSidePropsContext, NextPage } from "next"; 3 | import Head from "next/head"; 4 | import Image from "next/image"; 5 | import dynamic from "next/dynamic"; 6 | import { signIn, signOut, useSession } from "next-auth/react"; 7 | import dayjs from "dayjs"; 8 | import relativeTime from "dayjs/plugin/relativeTime"; 9 | dayjs.extend(relativeTime); 10 | import { usePlausible } from "next-plausible"; 11 | 12 | import { 13 | FaCaretSquareRight, 14 | FaSignOutAlt, 15 | FaSortAmountDown, 16 | FaSortAmountUp, 17 | FaTrash, 18 | FaTwitch, 19 | FaWindowRestore, 20 | FaQuestionCircle, 21 | FaEye, 22 | FaEyeSlash, 23 | FaEllipsisV, 24 | FaTimes, 25 | FaLink, 26 | FaPlug, 27 | } from "react-icons/fa"; 28 | 29 | import { getZapdosAuthSession } from "../server/common/get-server-session"; 30 | 31 | import Background from "../assets/background.svg"; 32 | import LoadingSVG from "../assets/puff.svg"; 33 | 34 | import { Button } from "../components/button"; 35 | import { Card } from "../components/card"; 36 | import { AutoAnimate } from "../components/auto-animate"; 37 | 38 | import { 39 | PusherProvider, 40 | useCurrentMemberCount, 41 | useSubscribeToEvent, 42 | } from "../utils/pusher"; 43 | import { trpc } from "../utils/trpc"; 44 | import Dropdown from "../components/dropdown"; 45 | import { Modal } from "../components/modal"; 46 | import { ChatbotWalkthrough } from "../components/chatbot-walkthrough"; 47 | import { useConfirmationModal } from "../components/confirmation-modal"; 48 | 49 | const QuestionsView = () => { 50 | const { data: sesh } = useSession(); 51 | const { data, isLoading, refetch } = trpc.proxy.questions.getAll.useQuery(); 52 | 53 | const plausible = usePlausible(); 54 | 55 | // Refetch when new questions come through 56 | useSubscribeToEvent("new-question", () => refetch()); 57 | 58 | const connectionCount = useCurrentMemberCount() - 1; 59 | const [reverseSort, setReverseSort] = useState(false); 60 | 61 | // Question pinning mutation 62 | const { 63 | mutate: pinQuestionMutation, 64 | variables: currentlyPinned, // The "variables" passed are the currently pinned Q 65 | reset: resetPinnedQuestionMutation, // The reset allows for "unpinning" on client 66 | } = trpc.proxy.questions.pin.useMutation(); 67 | const pinnedId = 68 | currentlyPinned?.questionId ?? data?.find((q) => q.status === "PINNED")?.id; 69 | 70 | const { mutate: unpinQuestion } = trpc.proxy.questions.unpin.useMutation({ 71 | onMutate: () => { 72 | resetPinnedQuestionMutation(); // Reset variables from mutation to "unpin" 73 | }, 74 | }); 75 | 76 | const tctx = trpc.useContext(); 77 | const { mutate: removeQuestionMutation } = 78 | trpc.proxy.questions.archive.useMutation({ 79 | onMutate: ({ questionId }) => { 80 | // Optimistic update 81 | tctx.queryClient.setQueryData( 82 | ["questions.getAll", null], 83 | data?.filter((q) => q.id !== questionId) 84 | ); 85 | }, 86 | }); 87 | 88 | const { mutate: clearQuestionsMutation } = 89 | trpc.proxy.questions.archiveAll.useMutation({ 90 | onMutate: () => { 91 | // Optimistic update 92 | tctx.queryClient.setQueryData(["questions.getAll", null], []); 93 | }, 94 | }); 95 | 96 | const clearQuestions = async ({location}: {location:string}) => { 97 | await clearQuestionsMutation(); 98 | plausible("Clear Questions", { props: { location } }); 99 | }; 100 | 101 | const removeQuestion = async ({ 102 | questionId, 103 | location, 104 | }: { 105 | questionId: string; 106 | location: string; 107 | }) => { 108 | await removeQuestionMutation({ questionId }); 109 | plausible("Remove Question", { props: { location } }); 110 | }; 111 | 112 | const pinQuestion = async ({ 113 | questionId, 114 | location, 115 | }: { 116 | questionId: string; 117 | location: string; 118 | }) => { 119 | await pinQuestionMutation({ questionId }); 120 | plausible("Pin Question", { props: { location } }); 121 | }; 122 | 123 | const modalState = useState(false); 124 | const [, setShowModal] = modalState; 125 | 126 | const showClearConfirmationModal = useConfirmationModal({ 127 | title: "Remove all questions?", 128 | icon: , 129 | variant: "danger", 130 | description: "This will remove all questions from the queue. This cannot be undone.", 131 | onConfirm: () => clearQuestions({location: "questionsMenu"}), 132 | confirmationLabel: "Remove all", 133 | }) 134 | 135 | if (isLoading) 136 | return ( 137 |
138 | loading... 139 |
140 | ); 141 | 142 | const selectedQuestion = data?.find((q) => q.id === pinnedId); 143 | const otherQuestions = data?.filter((q) => q.id !== pinnedId) || []; 144 | 145 | const otherQuestionsSorted = reverseSort 146 | ? [...otherQuestions].reverse() 147 | : otherQuestions; 148 | 149 | return ( 150 | <> 151 | 152 | 153 |
154 |

Connect a chatbot

155 | 159 |
160 | 161 |
162 |
163 |
164 |
165 | 166 |
167 |
168 |
169 |

Active Question

170 | 187 |
188 |
189 | 190 | {selectedQuestion ? ( 191 |

195 | {selectedQuestion?.body} 196 |

197 | ) : ( 198 |

199 | No active question 200 |

201 | )} 202 |
203 |
204 |
205 |
206 |
207 | 220 | 226 | 242 |
243 |
244 |
245 |
246 |
247 |

248 | Questions 249 | 250 | {otherQuestions.length} 251 | 252 | 258 |

259 | 260 | 264 | 265 | 266 | } 267 | items={[ 268 | { 269 | label: ( 270 | <> 271 | 272 | Copy Q&A URL 273 | 274 | ), 275 | onClick: () => { 276 | plausible("Copied Q&A URL", { 277 | props: { 278 | location: "questionsMenu", 279 | }, 280 | }); 281 | copyUrlToClipboard( 282 | `/ask/${sesh?.user?.name?.toLowerCase()}` 283 | ); 284 | }, 285 | }, 286 | { 287 | label: ( 288 | <> 289 | 290 | Connect Chatbot 291 | 292 | ), 293 | onClick: () => { 294 | setShowModal(true); 295 | }, 296 | }, 297 | { 298 | label: ( 299 | <> 300 | 301 | Clear Questions 302 | 303 | ), 304 | onClick: () => { 305 | showClearConfirmationModal() 306 | }, 307 | disabled: otherQuestions.length === 0, 308 | }, 309 | ]} 310 | /> 311 |
312 | 313 | {otherQuestionsSorted.length > 0 ? ( 314 | 318 | {otherQuestionsSorted.map((q) => ( 319 |
  • 320 | 321 |
    {q.body}
    322 |
    323 |
    324 | {dayjs(q.createdAt).fromNow()} 325 |
    326 | 338 |
    339 | 353 |
    354 |
  • 355 | ))} 356 |
    357 | ) : ( 358 |
    359 | 360 |

    361 | {"It's awfully quiet here..."} 362 |

    363 |

    364 | Share the Q&A link to get some questions 365 |

    366 |
    367 | 382 |
    383 |
    384 | )} 385 |
    386 |
    387 |
    388 | 389 | ); 390 | }; 391 | 392 | function QuestionsViewWrapper() { 393 | const { data: sesh } = useSession(); 394 | 395 | if (!sesh || !sesh.user?.id) return null; 396 | 397 | return ( 398 | 399 | 400 | 401 | ); 402 | } 403 | 404 | const LazyQuestionsView = dynamic(() => Promise.resolve(QuestionsViewWrapper), { 405 | ssr: false, 406 | }); 407 | 408 | const copyUrlToClipboard = (path: string) => { 409 | if (!process.browser) return; 410 | navigator.clipboard.writeText(`${window.location.origin}${path}`); 411 | }; 412 | 413 | const NavButtons: React.FC<{ userId: string }> = ({ userId }) => { 414 | const { data: sesh } = useSession(); 415 | 416 | return ( 417 |
    418 |

    419 | {sesh?.user?.image && ( 420 | pro pic 425 | )} 426 | {sesh?.user?.name} 427 |

    428 | 429 | 435 |
    436 | ); 437 | }; 438 | 439 | const HomeContents = () => { 440 | const { data } = useSession(); 441 | 442 | if (!data) 443 | return ( 444 |
    445 |
    446 | Ping Ask{" "} 447 | 448 | [BETA] 449 | 450 |
    451 |
    452 | An easy way to curate questions from your audience and embed them in 453 | your OBS. 454 |
    455 | 464 |
    465 | ); 466 | 467 | return ( 468 |
    469 |
    470 |
    471 | Ping Ask{" "} 472 | 473 | [BETA] 474 | 475 |
    476 | 477 |
    478 | 479 |
    480 | ); 481 | }; 482 | 483 | const Home: NextPage = () => { 484 | return ( 485 | <> 486 | 487 | {"Stream Q&A Tool"} 488 | 489 | 490 | 491 | 492 |
    496 | 497 |
    498 | 499 | Made with ♥ by{" "} 500 | 506 | Ping.gg 507 | 508 | 509 | 535 |
    536 |
    537 | 538 | ); 539 | }; 540 | 541 | export const getServerSideProps = async (ctx: GetServerSidePropsContext) => { 542 | return { 543 | props: { 544 | session: await getZapdosAuthSession(ctx), 545 | }, 546 | }; 547 | }; 548 | 549 | export default Home; 550 | -------------------------------------------------------------------------------- /src/server/common/get-server-session.ts: -------------------------------------------------------------------------------- 1 | import { GetServerSidePropsContext } from "next"; 2 | import { unstable_getServerSession } from "next-auth"; 3 | import { authOptions as nextAuthOptions } from "../../pages/api/auth/[...nextauth]"; 4 | 5 | export const getZapdosAuthSession = async (ctx: { 6 | req: GetServerSidePropsContext["req"]; 7 | res: GetServerSidePropsContext["res"]; 8 | }) => { 9 | return await unstable_getServerSession(ctx.req, ctx.res, nextAuthOptions); 10 | }; 11 | -------------------------------------------------------------------------------- /src/server/common/pusher.ts: -------------------------------------------------------------------------------- 1 | import PusherServer from "pusher"; 2 | import { env } from "../env"; 3 | 4 | export const pusherServerClient = new PusherServer({ 5 | appId: env.PUSHER_APP_ID!, 6 | key: env.NEXT_PUBLIC_PUSHER_APP_KEY!, 7 | secret: env.PUSHER_APP_SECRET!, 8 | host: env.NEXT_PUBLIC_PUSHER_SERVER_HOST!, 9 | port: env.NEXT_PUBLIC_PUSHER_SERVER_PORT!, 10 | useTLS: env.NEXT_PUBLIC_PUSHER_SERVER_TLS === 'true', 11 | cluster: env.NEXT_PUBLIC_PUSHER_SERVER_CLUSTER!, 12 | }); 13 | -------------------------------------------------------------------------------- /src/server/db/client.ts: -------------------------------------------------------------------------------- 1 | // src/server/db/client.ts 2 | import { PrismaClient } from "@prisma/client"; 3 | import { env } from "../env"; 4 | 5 | declare global { 6 | var prisma: PrismaClient | undefined; 7 | } 8 | 9 | export const prisma = global.prisma || new PrismaClient({}); 10 | 11 | if (env.NODE_ENV !== "production") { 12 | global.prisma = prisma; 13 | } 14 | -------------------------------------------------------------------------------- /src/server/env-schema.js: -------------------------------------------------------------------------------- 1 | const { z } = require("zod"); 2 | 3 | const envSchema = z.object({ 4 | DATABASE_URL: z.string().url(), 5 | NODE_ENV: z.enum(["development", "test", "production"]), 6 | NEXTAUTH_SECRET: z.string(), 7 | NEXTAUTH_URL: z.string().url(), 8 | TWITCH_CLIENT_ID: z.string(), 9 | TWITCH_CLIENT_SECRET: z.string(), 10 | PUSHER_APP_ID: z.string(), 11 | PUSHER_APP_SECRET: z.string(), 12 | NEXT_PUBLIC_PUSHER_APP_KEY: z.string(), 13 | NEXT_PUBLIC_PUSHER_SERVER_HOST: z.string(), 14 | NEXT_PUBLIC_PUSHER_SERVER_PORT: z.string(), 15 | NEXT_PUBLIC_PUSHER_SERVER_TLS: z.string(), 16 | NEXT_PUBLIC_PUSHER_SERVER_CLUSTER: z.string().default(null).optional(), 17 | DISCORD_NEW_USER_WEBHOOK: z.string().optional(), 18 | }); 19 | 20 | module.exports.envSchema = envSchema; 21 | -------------------------------------------------------------------------------- /src/server/env.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | /** 3 | * This file is included in `/next.config.js` which ensures the app isn't built with invalid env vars. 4 | * It has to be a `.js`-file to be imported there. 5 | */ 6 | const { envSchema } = require("./env-schema"); 7 | 8 | const env = envSchema.safeParse(process.env); 9 | 10 | const formatErrors = ( 11 | /** @type {import('zod').ZodFormattedError,string>} */ 12 | errors 13 | ) => 14 | Object.entries(errors) 15 | .map(([name, value]) => { 16 | if (value && "_errors" in value) 17 | return `${name}: ${value._errors.join(", ")}\n`; 18 | }) 19 | .filter(Boolean); 20 | 21 | if (!env.success) { 22 | console.error( 23 | "❌ Invalid environment variables:\n", 24 | ...formatErrors(env.error.format()) 25 | ); 26 | process.exit(1); 27 | } 28 | 29 | module.exports.env = env.data; 30 | -------------------------------------------------------------------------------- /src/server/router/index.ts: -------------------------------------------------------------------------------- 1 | // src/server/router/index.ts 2 | import { newQuestionRouter } from "./subroutes/question"; 3 | import { t } from "./trpc"; 4 | 5 | export const appRouter = t.router({ 6 | questions: newQuestionRouter, 7 | }); 8 | 9 | // export type definition of API 10 | export type AppRouter = typeof appRouter; 11 | -------------------------------------------------------------------------------- /src/server/router/subroutes/question.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { TRPCError } from "@trpc/server"; 3 | 4 | import { t } from "../trpc"; 5 | import { protectedProcedure } from "../utils/protected-procedure"; 6 | import { pusherServerClient } from "../../common/pusher"; 7 | 8 | export const newQuestionRouter = t.router({ 9 | submit: t.procedure 10 | .input( 11 | z.object({ 12 | userId: z.string(), 13 | question: z.string().min(0).max(400), 14 | }) 15 | ) 16 | .mutation(async ({ ctx, input }) => { 17 | const question = await ctx.prisma.question.create({ 18 | data: { 19 | userId: input.userId, 20 | body: input.question, 21 | }, 22 | }); 23 | 24 | await pusherServerClient.trigger( 25 | `user-${input.userId}`, 26 | "new-question", 27 | {} 28 | ); 29 | 30 | return question; 31 | }), 32 | 33 | getAll: protectedProcedure.query(async ({ ctx }) => { 34 | const questions = await ctx.prisma.question.findMany({ 35 | where: { 36 | userId: ctx.session.user.id, 37 | OR: [ 38 | { 39 | status: "PENDING", 40 | }, 41 | { 42 | status: "PINNED", 43 | }, 44 | ], 45 | }, 46 | orderBy: { id: "asc" }, 47 | }); 48 | 49 | return questions; 50 | }), 51 | 52 | pin: protectedProcedure 53 | .input(z.object({ questionId: z.string() })) 54 | .mutation(async ({ ctx, input }) => { 55 | const question = await ctx.prisma.question.findFirst({ 56 | where: { id: input.questionId }, 57 | }); 58 | if (!question || question.userId !== ctx.session.user.id) { 59 | throw new TRPCError({ 60 | message: "NOT YOUR QUESTION", 61 | code: "UNAUTHORIZED", 62 | }); 63 | } 64 | 65 | await ctx.prisma.question.updateMany({ 66 | where: { userId: ctx.session.user.id, status: "PINNED" }, 67 | data: { 68 | status: "PENDING", 69 | }, 70 | }); 71 | 72 | await ctx.prisma.question.update({ 73 | where: { id: input.questionId }, 74 | data: { status: "PINNED" }, 75 | }); 76 | 77 | await pusherServerClient.trigger( 78 | `user-${question.userId}`, 79 | "question-pinned", 80 | { 81 | question: question.body, 82 | } 83 | ); 84 | return question; 85 | }), 86 | 87 | archive: protectedProcedure 88 | .input(z.object({ questionId: z.string() })) 89 | .mutation(async ({ ctx, input }) => { 90 | return await ctx.prisma.question.updateMany({ 91 | where: { id: input.questionId, userId: ctx.session.user.id }, 92 | data: { 93 | status: "ANSWERED", 94 | }, 95 | }); 96 | }), 97 | 98 | archiveAll: protectedProcedure.mutation(async ({ ctx, input }) => { 99 | return await ctx.prisma.question.updateMany({ 100 | where: { userId: ctx.session.user.id, status: "PENDING" }, 101 | data: { 102 | status: "ANSWERED", 103 | }, 104 | }); 105 | }), 106 | 107 | unpin: protectedProcedure.mutation(async ({ ctx }) => { 108 | // set pinned question to pending 109 | await ctx.prisma.question.updateMany({ 110 | where: { userId: ctx.session.user.id, status: "PINNED" }, 111 | data: { 112 | status: "PENDING", 113 | }, 114 | }); 115 | 116 | await pusherServerClient.trigger( 117 | `user-${ctx.session.user?.id}`, 118 | "question-unpinned", 119 | {} 120 | ); 121 | }), 122 | }); 123 | -------------------------------------------------------------------------------- /src/server/router/trpc/context.ts: -------------------------------------------------------------------------------- 1 | import * as trpc from "@trpc/server"; 2 | import * as trpcNext from "@trpc/server/adapters/next"; 3 | import { getZapdosAuthSession } from "../../common/get-server-session"; 4 | import { prisma } from "../../db/client"; 5 | 6 | export async function createContext(opts: trpcNext.CreateNextContextOptions) { 7 | const req = opts?.req; 8 | const res = opts?.res; 9 | 10 | const session = req && res && (await getZapdosAuthSession({ req, res })); 11 | 12 | return { session, prisma }; 13 | } 14 | export type Context = trpc.inferAsyncReturnType; 15 | -------------------------------------------------------------------------------- /src/server/router/trpc/index.ts: -------------------------------------------------------------------------------- 1 | import { Context } from "./context"; 2 | import { initTRPC } from "@trpc/server"; 3 | import superjson from "superjson"; 4 | 5 | export const t = initTRPC<{ 6 | ctx: Context; 7 | }>()({ 8 | transformer: superjson, 9 | errorFormatter({ shape }) { 10 | return shape; 11 | }, 12 | }); 13 | -------------------------------------------------------------------------------- /src/server/router/utils/protected-procedure.ts: -------------------------------------------------------------------------------- 1 | import { TRPCError } from "@trpc/server"; 2 | import { t } from "../trpc"; 3 | 4 | export const protectedProcedure = t.procedure.use(({ ctx, next }) => { 5 | if (!ctx.session || !ctx.session.user) { 6 | throw new TRPCError({ code: "UNAUTHORIZED" }); 7 | } 8 | return next({ 9 | ctx: { 10 | ...ctx, 11 | // infers that `session` is non-nullable to downstream resolvers 12 | session: { ...ctx.session, user: ctx.session.user }, 13 | }, 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /src/styles/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | body { 6 | @apply bg-gray-900 text-gray-100; 7 | } 8 | 9 | /* 10 | * Help Chrome word break better 11 | */ 12 | .break-words { 13 | word-break: break-word; 14 | } 15 | -------------------------------------------------------------------------------- /src/types/next-auth.d.ts: -------------------------------------------------------------------------------- 1 | import { DefaultSession } from "next-auth"; 2 | import "next-auth"; 3 | 4 | declare module "next-auth" { 5 | /** 6 | * Returned by `useSession`, `getSession` and received as a prop on the `SessionProvider` React Context 7 | */ 8 | interface Session { 9 | user?: { 10 | id?: string; 11 | } & DefaultSession["user"]; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/utils/pusher.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Section 1: "The Store" 3 | * 4 | * This defines a Pusher client and channel connection as a vanilla Zustand store. 5 | */ 6 | import Pusher, { Channel, PresenceChannel } from "pusher-js"; 7 | import vanillaCreate, { StoreApi } from "zustand/vanilla"; 8 | 9 | const pusher_key = process.env.NEXT_PUBLIC_PUSHER_APP_KEY!; 10 | const pusher_server_host = process.env.NEXT_PUBLIC_PUSHER_SERVER_HOST!; 11 | const pusher_server_port = parseInt( 12 | process.env.NEXT_PUBLIC_PUSHER_SERVER_PORT!, 13 | 10 14 | ); 15 | const pusher_server_tls = process.env.NEXT_PUBLIC_PUSHER_SERVER_TLS === "true"; 16 | const pusher_server_cluster = process.env.NEXT_PUBLIC_PUSHER_SERVER_CLUSTER!; 17 | 18 | interface PusherZustandStore { 19 | pusherClient: Pusher; 20 | channel: Channel; 21 | presenceChannel: PresenceChannel; 22 | members: Map; 23 | } 24 | 25 | const createPusherStore = (slug: string) => { 26 | let pusherClient: Pusher; 27 | if (Pusher.instances.length) { 28 | pusherClient = Pusher.instances[0] as Pusher; 29 | pusherClient.connect(); 30 | } else { 31 | const randomUserId = `random-user-id:${Math.random().toFixed(7)}`; 32 | pusherClient = new Pusher(pusher_key, { 33 | wsHost: pusher_server_host, 34 | wsPort: pusher_server_port, 35 | enabledTransports: pusher_server_tls ? ["ws", "wss"] : ["ws"], 36 | forceTLS: pusher_server_tls, 37 | cluster: pusher_server_cluster, 38 | disableStats: true, 39 | authEndpoint: "/api/pusher/auth-channel", 40 | auth: { 41 | headers: { user_id: randomUserId }, 42 | }, 43 | }); 44 | } 45 | 46 | const channel = pusherClient.subscribe(slug); 47 | 48 | const presenceChannel = pusherClient.subscribe( 49 | `presence-${slug}` 50 | ) as PresenceChannel; 51 | 52 | const store = vanillaCreate(() => { 53 | return { 54 | pusherClient: pusherClient, 55 | channel: channel, 56 | presenceChannel, 57 | members: new Map(), 58 | }; 59 | }); 60 | 61 | // Update helper that sets 'members' to contents of presence channel's current members 62 | const updateMembers = () => { 63 | store.setState(() => ({ 64 | members: new Map(Object.entries(presenceChannel.members.members)), 65 | })); 66 | }; 67 | 68 | // Bind all "present users changed" events to trigger updateMembers 69 | presenceChannel.bind("pusher:subscription_succeeded", updateMembers); 70 | presenceChannel.bind("pusher:member_added", updateMembers); 71 | presenceChannel.bind("pusher:member_removed", updateMembers); 72 | 73 | return store; 74 | }; 75 | 76 | /** 77 | * Section 2: "The Context Provider" 78 | * 79 | * This creates a "Zustand React Context" that we can provide in the component tree. 80 | */ 81 | import createContext from "zustand/context"; 82 | const { 83 | Provider: PusherZustandStoreProvider, 84 | useStore: usePusherZustandStore, 85 | } = createContext>(); 86 | 87 | import React, { useEffect, useState } from "react"; 88 | 89 | /** 90 | * This provider is the thing you mount in the app to "give access to Pusher" 91 | * 92 | */ 93 | export const PusherProvider: React.FC< 94 | React.PropsWithChildren<{ slug: string }> 95 | > = ({ slug, children }) => { 96 | const [store, updateStore] = useState>(); 97 | 98 | useEffect(() => { 99 | const newStore = createPusherStore(slug); 100 | updateStore(newStore); 101 | return () => { 102 | const pusher = newStore.getState().pusherClient; 103 | console.log("disconnecting pusher and destroying store", pusher); 104 | console.log( 105 | "(Expect a warning in terminal after this, React Dev Mode and all)" 106 | ); 107 | pusher.disconnect(); 108 | newStore.destroy(); 109 | }; 110 | }, [slug]); 111 | 112 | if (!store) return null; 113 | 114 | return ( 115 | store}> 116 | {children} 117 | 118 | ); 119 | }; 120 | 121 | /** 122 | * Section 3: "The Hooks" 123 | * 124 | * The exported hooks you use to interact with this store (in this case just an event sub) 125 | * 126 | * (I really want useEvent tbh) 127 | */ 128 | export function useSubscribeToEvent( 129 | eventName: string, 130 | callback: (data: MessageType) => void 131 | ) { 132 | const channel = usePusherZustandStore((state) => state.channel); 133 | 134 | const stableCallback = React.useRef(callback); 135 | 136 | // Keep callback sync'd 137 | React.useEffect(() => { 138 | stableCallback.current = callback; 139 | }, [callback]); 140 | 141 | React.useEffect(() => { 142 | const reference = (data: MessageType) => { 143 | stableCallback.current(data); 144 | }; 145 | channel.bind(eventName, reference); 146 | return () => { 147 | channel.unbind(eventName, reference); 148 | }; 149 | }, [channel, eventName]); 150 | } 151 | 152 | export const useCurrentMemberCount = () => 153 | usePusherZustandStore((s) => s.members.size); 154 | -------------------------------------------------------------------------------- /src/utils/trpc.ts: -------------------------------------------------------------------------------- 1 | import { httpBatchLink, loggerLink } from "@trpc/client"; 2 | import { setupTRPC } from "@trpc/next"; 3 | import type { inferProcedureInput, inferProcedureOutput } from "@trpc/server"; 4 | import { NextPageContext } from "next"; 5 | import superjson from "superjson"; 6 | import type { AppRouter } from "../server/router"; 7 | // ℹ️ Type-only import: 8 | // https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-8.html#type-only-imports-and-export 9 | 10 | function getBaseUrl() { 11 | if (typeof window !== "undefined") { 12 | return ""; 13 | } 14 | 15 | // reference for vercel.com 16 | if (process.env.VERCEL_URL) { 17 | return `https://${process.env.VERCEL_URL}`; 18 | } 19 | 20 | // assume localhost 21 | return `http://localhost:${process.env.PORT ?? 3000}`; 22 | } 23 | 24 | /** 25 | * Extend `NextPageContext` with meta data that can be picked up by `responseMeta()` when server-side rendering 26 | */ 27 | export interface SSRContext extends NextPageContext { 28 | /** 29 | * Set HTTP Status code 30 | * @example 31 | * const utils = trpc.useContext(); 32 | * if (utils.ssrContext) { 33 | * utils.ssrContext.status = 404; 34 | * } 35 | */ 36 | status?: number; 37 | } 38 | 39 | /** 40 | * A set of strongly-typed React hooks from your `AppRouter` type signature with `createReactQueryHooks`. 41 | * @link https://trpc.io/docs/react#3-create-trpc-hooks 42 | */ 43 | export const trpc = setupTRPC({ 44 | config() { 45 | /** 46 | * If you want to use SSR, you need to use the server's full URL 47 | * @link https://trpc.io/docs/ssr 48 | */ 49 | return { 50 | /** 51 | * @link https://trpc.io/docs/data-transformers 52 | */ 53 | transformer: superjson, 54 | /** 55 | * @link https://trpc.io/docs/links 56 | */ 57 | links: [ 58 | // adds pretty logs to your console in development and logs errors in production 59 | loggerLink({ 60 | enabled: (opts) => 61 | process.env.NODE_ENV === "development" || 62 | (opts.direction === "down" && opts.result instanceof Error), 63 | }), 64 | 65 | httpBatchLink({ 66 | url: `${getBaseUrl()}/api/trpc`, 67 | }), 68 | ], 69 | /** 70 | * @link https://react-query.tanstack.com/reference/QueryClient 71 | */ 72 | // queryClientConfig: { defaultOptions: { queries: { staleTime: 60 } } }, 73 | }; 74 | }, 75 | /** 76 | * @link https://trpc.io/docs/ssr 77 | */ 78 | ssr: false, 79 | }); 80 | 81 | /** 82 | * This is a helper method to infer the output of a query resolver 83 | * @example type HelloOutput = inferQueryOutput<'hello'> 84 | */ 85 | export type inferQueryOutput< 86 | TRouteKey extends keyof AppRouter["_def"]["queries"] 87 | > = inferProcedureOutput; 88 | 89 | export type inferQueryInput< 90 | TRouteKey extends keyof AppRouter["_def"]["queries"] 91 | > = inferProcedureInput; 92 | 93 | export type inferMutationOutput< 94 | TRouteKey extends keyof AppRouter["_def"]["mutations"] 95 | > = inferProcedureOutput; 96 | 97 | export type inferMutationInput< 98 | TRouteKey extends keyof AppRouter["_def"]["mutations"] 99 | > = inferProcedureInput; 100 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | const colors = require("tailwindcss/colors"); 3 | const typography = require("./tailwind.typography.config"); 4 | 5 | module.exports = { 6 | content: ["./src/**/*.{js,ts,jsx,tsx}"], 7 | theme: { 8 | extend: { 9 | typography, 10 | colors: { 11 | gray: { 12 | ...colors.zinc, 13 | 750: "#333338", 14 | 850: "#202023", 15 | 950: "#0C0C0E", 16 | }, 17 | pink: { 18 | 50: "#FEE6F0", 19 | 100: "#FDCDE1", 20 | 200: "#F1A5C6", 21 | 300: "#ED8AB5", 22 | 400: "#E96EA4", 23 | 500: "#E24A8D", 24 | 600: "#DB1D70", 25 | 700: "#C01A62", 26 | 800: "#A41654", 27 | 900: "#6E0F38", 28 | }, 29 | }, 30 | keyframes: { 31 | "fade-in": { 32 | "0%": { 33 | opacity: "0", 34 | }, 35 | "100%": { 36 | opacity: "1", 37 | }, 38 | }, 39 | "fade-in-delay": { 40 | "0%": { 41 | opacity: "0", 42 | }, 43 | "50%": { 44 | opacity: "0", 45 | }, 46 | "100%": { 47 | opacity: "1", 48 | }, 49 | }, 50 | "fade-in-down": { 51 | "0%": { 52 | opacity: "0", 53 | transform: "translateY(-10px)", 54 | }, 55 | "100%": { 56 | opacity: "1", 57 | transform: "translateY(0)", 58 | }, 59 | }, 60 | }, 61 | animation: { 62 | "fade-in-down": "fade-in-down 0.5s ease-out", 63 | "fade-in": "fade-in 0.2s ease-out", 64 | "fade-in-delay": "fade-in-delay 1s ease-out", 65 | }, 66 | backgroundSize: { 67 | landing: "120rem", 68 | }, 69 | }, 70 | }, 71 | plugins: [require("@tailwindcss/typography")], 72 | }; 73 | -------------------------------------------------------------------------------- /tailwind.typography.config.js: -------------------------------------------------------------------------------- 1 | /* 2 | Config for our tailwind typography instance 3 | @see https://github.com/tailwindlabs/tailwindcss-typography/blob/master/src/styles.js 4 | */ 5 | const round = (num) => 6 | num 7 | .toFixed(7) 8 | .replace(/(\.[0-9]+?)0+$/, "$1") 9 | .replace(/\.0$/, ""); 10 | const em = (px, base) => `${round(px / base)}em`; 11 | 12 | module.exports = (theme) => ({ 13 | sm: { 14 | css: { 15 | kdb: { 16 | fontSize: em(12, 14), 17 | }, 18 | }, 19 | }, 20 | DEFAULT: { 21 | css: { 22 | color: theme("colors.gray.100"), 23 | strong: { 24 | color: theme("colors.gray.200"), 25 | }, 26 | a: { 27 | color: theme("colors.pink.400"), 28 | textDecoration: "none", 29 | "&:hover": { 30 | color: theme("colors.pink.400"), 31 | textDecoration: "underline", 32 | }, 33 | }, 34 | kbd: { 35 | color: theme("colors.gray.300"), 36 | fontSize: em(14, 16), 37 | fontWeight: theme("fontWeight.medium"), 38 | backgroundColor: theme("colors.gray.750"), 39 | borderColor: theme("colors.gray.700"), 40 | borderWidth: theme("borderWidth.DEFAULT"), 41 | borderRadius: theme("borderRadius.sm"), 42 | padding: theme("spacing[0.5]"), 43 | }, 44 | code: { 45 | color: theme("colors.gray.300"), 46 | fontSize: em(14, 16), 47 | fontWeight: theme("fontWeight.medium"), 48 | backgroundColor: theme("colors.gray.800"), 49 | borderColor: theme("colors.gray.750"), 50 | borderWidth: theme("borderWidth.DEFAULT"), 51 | borderRadius: theme("borderRadius.sm"), 52 | padding: theme("spacing[0.5]"), 53 | "&::before": { 54 | display: "none", 55 | }, 56 | "&::after": { 57 | display: "none", 58 | }, 59 | }, 60 | pre: { 61 | backgroundColor: theme("colors.gray.900"), 62 | }, 63 | h2: { 64 | color: theme("colors.gray.300"), 65 | }, 66 | h3: { 67 | color: theme("colors.gray.300"), 68 | }, 69 | h4: { 70 | color: theme("colors.gray.300"), 71 | }, 72 | h5: { 73 | color: theme("colors.gray.300"), 74 | }, 75 | ol: { 76 | li: { 77 | "&::before": { 78 | color: theme("colors.gray.300"), 79 | }, 80 | }, 81 | }, 82 | th: { 83 | color: theme("colors.gray.300"), 84 | }, 85 | }, 86 | }, 87 | }); 88 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true, 17 | "noUncheckedIndexedAccess": true 18 | }, 19 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "**/*.js"], 20 | "exclude": ["node_modules"] 21 | } 22 | --------------------------------------------------------------------------------