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 | Parameter |
73 | Description |
74 |
75 |
76 |
77 |
78 |
79 | q
80 | |
81 | The question content to be submitted. |
82 |
83 |
84 |
85 | channel
86 | |
87 |
88 | The Twitch channel (username) where the question is being asked.
89 | |
90 |
91 |
92 |
93 | user
94 | |
95 | The username of the Twitch user submitting the question. |
96 |
97 |
98 |
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 |
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 |
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 |
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 |
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 |

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 |
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 |
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 |
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