├── .env.example ├── .eslintrc.json ├── .gitignore ├── LICENSE ├── README.md ├── next.config.mjs ├── package.json ├── postcss.config.cjs ├── prisma └── schema.prisma ├── public └── favicon.ico ├── src ├── client │ ├── clips │ │ └── TwitchClipsGrid.tsx │ ├── container │ │ └── MainContainer.tsx │ ├── nav │ │ ├── LoginButton.tsx │ │ ├── Navbar.tsx │ │ └── acorn.png │ ├── text │ │ └── OverflowText.tsx │ └── theme │ │ └── SiteTheme.tsx ├── env │ ├── client.mjs │ ├── schema.mjs │ └── server.mjs ├── pages │ ├── _app.css │ ├── _app.tsx │ ├── api │ │ ├── auth │ │ │ └── [...nextauth].ts │ │ ├── restricted.ts │ │ └── trpc │ │ │ └── [trpc].ts │ └── index.tsx ├── server │ ├── common │ │ ├── get-server-auth-session.ts │ │ └── third_party │ │ │ └── twitch │ │ │ ├── TwitchGraphApi.ts │ │ │ └── TwitchQueryer.ts │ ├── db │ │ └── client.ts │ └── trpc │ │ ├── context.ts │ │ ├── router │ │ ├── _app.ts │ │ ├── auth.ts │ │ └── clips.ts │ │ └── trpc.ts ├── types │ └── next-auth.d.ts └── utils │ └── trpc.ts ├── tailwind.config.cjs └── tsconfig.json /.env.example: -------------------------------------------------------------------------------- 1 | # Since .env is gitignored, you can use .env.example to build a new `.env` file when you clone the repo. 2 | # Keep this file up-to-date when you add new variables to `.env`. 3 | 4 | # This file will be committed to version control, so make sure not to have any secrets in it. 5 | # If you are cloning this repo, create a copy of this file named `.env` and populate it with your secrets. 6 | 7 | # When adding additional env variables, the schema in /env/schema.mjs should be updated accordingly 8 | 9 | # Prisma 10 | DATABASE_URL=file:./db.sqlite 11 | 12 | # Next Auth 13 | # You can generate the secret via 'openssl rand -base64 32' on Linux 14 | # More info: https://next-auth.js.org/configuration/options#secret 15 | # NEXTAUTH_SECRET= 16 | NEXTAUTH_URL=http://localhost:3000 17 | 18 | # Next Auth Twitch Provider 19 | TWITCH_CLIENT_ID= 20 | TWITCH_CLIENT_SECRET= 21 | 22 | UPSTASH_REDIS_REST_URL= 23 | UPSTASH_REDIS_REST_TOKEN= 24 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "parserOptions": { 4 | "project": "./tsconfig.json" 5 | }, 6 | "plugins": ["@typescript-eslint"], 7 | "extends": ["next/core-web-vitals", "plugin:@typescript-eslint/recommended"], 8 | "rules": { 9 | "@typescript-eslint/consistent-type-imports": "warn", 10 | "@typescript-eslint/no-empty-function": "off", 11 | "@typescript-eslint/no-non-null-assertion": "off", 12 | "@typescript-eslint/no-explicit-any": "off", 13 | "react/no-unescaped-entities": "off", 14 | "@typescript-eslint/ban-types": [ 15 | "error", 16 | { 17 | "extendDefaults": true, 18 | "types": { 19 | "{}": false 20 | } 21 | } 22 | ] 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /.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 | # database 12 | /prisma/db.sqlite 13 | /prisma/db.sqlite-journal 14 | 15 | # next.js 16 | /.next/ 17 | /out/ 18 | next-env.d.ts 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | .idea 26 | *.pem 27 | 28 | # debug 29 | npm-debug.log* 30 | yarn-debug.log* 31 | yarn-error.log* 32 | .pnpm-debug.log* 33 | 34 | # local env files 35 | .env 36 | .env*.local 37 | 38 | # vercel 39 | .vercel 40 | 41 | # typescript 42 | *.tsbuildinfo 43 | 44 | # ignore package lock files. Without this, vercel deploy breaks? 45 | package-lock.json 46 | pnpm-lock.yaml 47 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 acorn1010 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Create T3 App 2 | 3 | This is an app bootstrapped according to the [init.tips](https://init.tips) stack, also known as the T3-Stack. 4 | 5 | ## What's next? How do I make an app with this? 6 | 7 | We try to keep this project as simple as possible, so you can start with the most basic configuration and then move on to more advanced configuration. 8 | 9 | If you are not familiar with the different technologies used in this project, please refer to the respective docs. If you still are in the wind, please join our [Discord](https://t3.gg/discord) and ask for help. 10 | 11 | - [Next-Auth.js](https://next-auth.js.org) 12 | - [Prisma](https://prisma.io) 13 | - [TailwindCSS](https://tailwindcss.com) 14 | - [tRPC](https://trpc.io) 15 | 16 | We also [roll our own docs](https://create.t3.gg) with some summary information and links to the respective documentation. 17 | 18 | Also checkout these awesome tutorials on `create-t3-app`. 19 | 20 | - [Build a Blog With the T3 Stack - tRPC, TypeScript, Next.js, Prisma & Zod](https://www.youtube.com/watch?v=syEWlxVFUrY) 21 | - [Build a Live Chat Application with the T3 Stack - TypeScript, Tailwind, tRPC](https://www.youtube.com/watch?v=dXRRY37MPuk) 22 | - [Build a full stack app with create-t3-app](https://www.nexxel.dev/blog/ct3a-guestbook) 23 | - [A first look at create-t3-app](https://dev.to/ajcwebdev/a-first-look-at-create-t3-app-1i8f) 24 | 25 | ## How do I deploy this? 26 | 27 | Follow our deployment guides for [Vercel](https://create.t3.gg/en/deployment/vercel) and [Docker](https://create.t3.gg/en/deployment/docker) for more information. 28 | -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | /** 3 | * Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. 4 | * This is especially useful for Docker builds. 5 | */ 6 | !process.env.SKIP_ENV_VALIDATION && (await import("./src/env/server.mjs")); 7 | 8 | /** @type {import("next").NextConfig} */ 9 | const config = { 10 | reactStrictMode: true, 11 | swcMinify: true, 12 | i18n: { 13 | locales: ["en"], 14 | defaultLocale: "en", 15 | }, 16 | images: { 17 | formats: ['image/avif', 'image/webp'], 18 | }, 19 | }; 20 | export default config; 21 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "multiclips", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "build": "next build", 7 | "deploy": "npx prisma db push", 8 | "dev": "next dev", 9 | "postinstall": "prisma generate", 10 | "lint": "next lint", 11 | "start": "next start", 12 | "proxy": "pscale org switch multiclip && pscale connect multiclips main --port 3309" 13 | }, 14 | "dependencies": { 15 | "@emotion/react": "^11.10.5", 16 | "@emotion/styled": "^11.10.5", 17 | "@mui/material": "^5.10.16", 18 | "@mui/system": "^5.10.16", 19 | "@next-auth/prisma-adapter": "^1.0.5", 20 | "@next/font": "^13.4.6", 21 | "@prisma/client": "^4.7.1", 22 | "@tanstack/react-query": "^4.19.1", 23 | "@trpc/client": "10.0.0-rc.4", 24 | "@trpc/next": "10.0.0-rc.4", 25 | "@trpc/react-query": "10.0.0-rc.4", 26 | "@trpc/server": "10.0.0-rc.4", 27 | "@upstash/redis": "^1.18.1", 28 | "next": "^13.4.6", 29 | "next-auth": "^4.18.0", 30 | "react": "18.2.0", 31 | "react-dom": "18.2.0", 32 | "react-icons": "^4.9.0", 33 | "superjson": "1.11.0", 34 | "use-resize-observer": "^9.1.0", 35 | "zod": "^3.19.1", 36 | "zustand": "^4.3.8" 37 | }, 38 | "devDependencies": { 39 | "@types/node": "^18.11.10", 40 | "@types/react": "^18.0.26", 41 | "@types/react-dom": "^18.0.9", 42 | "@typescript-eslint/eslint-plugin": "^5.45.0", 43 | "@typescript-eslint/parser": "^5.45.0", 44 | "autoprefixer": "^10.4.13", 45 | "eslint": "^8.29.0", 46 | "eslint-config-next": "13.0.6", 47 | "postcss": "^8.4.20", 48 | "prisma": "^4.7.1", 49 | "tailwindcss": "^3.2.4", 50 | "typescript": "^4.9.3" 51 | }, 52 | "ct3aMetadata": { 53 | "initVersion": "6.10.1" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /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 | binaryTargets = ["native", "debian-openssl-3.0.x"] 6 | provider = "prisma-client-js" 7 | previewFeatures = ["referentialIntegrity"] 8 | } 9 | 10 | datasource db { 11 | provider = "mysql" 12 | // NOTE: When using postgresql, mysql or sqlserver, uncomment the @db.Text annotations in model Account below 13 | // Further reading: 14 | // https://next-auth.js.org/adapters/prisma#create-the-prisma-schema 15 | // https://www.prisma.io/docs/reference/api-reference/prisma-schema-reference#string 16 | url = env("DATABASE_URL") 17 | referentialIntegrity = "prisma" 18 | } 19 | 20 | // Necessary for Next auth 21 | model Account { 22 | id String @id @default(cuid()) 23 | userId String 24 | type String 25 | provider String 26 | providerAccountId String 27 | refresh_token String? @db.Text 28 | access_token String? @db.Text 29 | expires_at Int? 30 | token_type String? 31 | scope String? 32 | id_token String? @db.Text 33 | session_state String? 34 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 35 | 36 | @@unique([provider, providerAccountId]) 37 | } 38 | 39 | model Session { 40 | id String @id @default(cuid()) 41 | sessionToken String @unique 42 | userId String 43 | expires DateTime 44 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 45 | } 46 | 47 | model User { 48 | id String @id @default(cuid()) 49 | name String? 50 | email String? @unique 51 | emailVerified DateTime? 52 | image String? 53 | accounts Account[] 54 | sessions Session[] 55 | createdAt DateTime @default(now()) 56 | } 57 | 58 | model VerificationToken { 59 | identifier String 60 | token String @unique 61 | expires DateTime 62 | 63 | @@unique([identifier, token]) 64 | } 65 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/acorn1010/multiclip/d3914f8c20e2b4c01c114df54ef412c7b6b6e328/public/favicon.ico -------------------------------------------------------------------------------- /src/client/clips/TwitchClipsGrid.tsx: -------------------------------------------------------------------------------- 1 | import {type RouterInputs, trpc} from "../../utils/trpc"; 2 | import {Card, CardActionArea, CardMedia, FormControl, InputLabel, MenuItem, Select} from "@mui/material"; 3 | import {OverflowText} from "../text/OverflowText"; 4 | import type {TwitchClip} from "../../server/common/third_party/twitch/TwitchGraphApi"; 5 | import {useState} from "react"; 6 | import {FaUsers} from "react-icons/fa"; 7 | import clsx from "clsx"; 8 | 9 | type DateRange = RouterInputs['clips']['getAll']['dateRange']; 10 | 11 | /** Displays the logged-in user's Twitch clips. */ 12 | export function TwitchClipsGrid() { 13 | const [dateRange, setDateRange] = useState('30days' as DateRange); 14 | const { isError, data } = trpc.clips.getAll.useQuery({ dateRange }); 15 | 16 | return ( 17 |
18 |

{data ? `${data.length} ` : ''}CLIPS

19 | 20 | Time Period 21 | 32 | 33 |
34 | 35 |
36 |
37 | ); 38 | } 39 | 40 | function TwitchClipsGridClips({clips}: {clips: TwitchClip[] | undefined}) { 41 | if (!clips) { 42 | return

Loading Twitch Clips...

; 43 | } 44 | 45 | return <>{clips?.map(clip => )}; 46 | } 47 | 48 | function TwitchClipCard({clip}: {clip: TwitchClip}) { 49 | const {title, thumbnail_url, view_count, download_url} = clip; 50 | 51 | // TODO(acorn1010): Allow navigating to VOD at clip location. 52 | // e.g.: https://player.twitch.tv/?video=v${video_id}&parent=localhost&t=${vod_offset} 53 | return ( 54 | 55 | 56 | 57 | 62 |

66 | 67 | {view_count} 68 |

69 |
70 |
71 | ); 72 | } 73 | -------------------------------------------------------------------------------- /src/client/container/MainContainer.tsx: -------------------------------------------------------------------------------- 1 | import type {PropsWithChildren} from "react"; 2 | import {Navbar} from "../nav/Navbar"; 3 | 4 | export function MainContainer(props: PropsWithChildren<{}>) { 5 | return ( 6 | <> 7 | 8 |
9 | {props.children} 10 |
11 | 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /src/client/nav/LoginButton.tsx: -------------------------------------------------------------------------------- 1 | import {signIn, signOut, useSession} from "next-auth/react"; 2 | import {Avatar, Button, IconButton, Menu, MenuItem} from "@mui/material"; 3 | import {useState} from "react"; 4 | 5 | export function LoginButton() { 6 | const {data} = useSession(); 7 | 8 | // Logged in 9 | if (data?.user) { 10 | const {name, image} = data.user; 11 | return ; 12 | } 13 | 14 | // Not logged in 15 | return ; 16 | } 17 | 18 | /** Avatar button that shows when a user is logged in. */ 19 | function LoginAvatarButton(props: {name: string | undefined, image: string | undefined}) { 20 | const {name, image} = props; 21 | const [anchorEl, setAnchorEl] = useState(null); 22 | 23 | const close = () => setAnchorEl(null); 24 | return ( 25 | <> 26 | setAnchorEl(e.currentTarget)}> 27 | 28 | 29 | 30 | { 31 | close(); 32 | signOut().then(() => {}); 33 | }}>Log Out 34 | 35 | 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /src/client/nav/Navbar.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | AppBar, 3 | Toolbar, 4 | } from "@mui/material"; 5 | import acorn from './acorn.png'; 6 | import Image from 'next/image'; 7 | import React, {type PropsWithChildren} from "react"; 8 | import {FaDiscord, FaGithub, FaTiktok, FaTwitch, FaTwitter, FaYoutube} from "react-icons/fa"; 9 | 10 | export function Navbar() { 11 | return ( 12 | 13 | 14 |

15 | acorn avatar Acorn1010 16 |

17 | 18 |
19 |
20 | ); 21 | } 22 | 23 | function Links() { 24 | const iconClass = 'text-2xl md:text-3xl'; 25 | return ( 26 |
27 | 28 | 29 | 30 | 31 | 32 | 33 |
34 | ); 35 | } 36 | 37 | function SocialLink({children, to}: PropsWithChildren<{to: string}>) { 38 | return {children}; 39 | } 40 | -------------------------------------------------------------------------------- /src/client/nav/acorn.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/acorn1010/multiclip/d3914f8c20e2b4c01c114df54ef412c7b6b6e328/src/client/nav/acorn.png -------------------------------------------------------------------------------- /src/client/text/OverflowText.tsx: -------------------------------------------------------------------------------- 1 | import React, {type CSSProperties, useCallback, useState} from 'react'; 2 | import useResizeObserver from 'use-resize-observer'; 3 | import {Tooltip, type TooltipProps} from "@mui/material"; 4 | import clsx from "clsx"; 5 | 6 | type OverflowTextProps = { 7 | center?: boolean, 8 | className?: string, 9 | title: string, 10 | component?: 'h1', 11 | style?: CSSProperties, 12 | } & Pick; 13 | 14 | /** 15 | * Returns a

element wrapped in a tooltip. The tooltip will only be visible when the

element doesn't have enough 16 | * space (e.g. it has an ellipsis). 17 | */ 18 | export function OverflowText(props: OverflowTextProps) { 19 | const {center, className, placement, style, title, ...rest} = props; 20 | const [isOpen, setIsOpen] = useState(false); 21 | const {ref} = useResizeObserver(); 22 | 23 | const resizeCallback = useCallback((el: HTMLParagraphElement) => { 24 | if (!el) { 25 | return; 26 | } 27 | // scrollWidth doesn't include margin, so add margin to the child to see if it overflows the 28 | // parent. 29 | const {marginLeft, marginRight} = window.getComputedStyle(el); 30 | const outerChildWidth = el.scrollWidth + parseInt(marginLeft, 10) + parseInt(marginRight, 10); 31 | setIsOpen(!!el.parentElement && outerChildWidth >= el.parentElement.clientWidth); 32 | }, [setIsOpen]); 33 | 34 | // We wrap the Typography in a div to ensure that no other child interferes with the width of the 35 | // parent container. 36 | // TODO(acorn1010): Replace center prop with a better solution for centering text. 37 | const centerStyle: CSSProperties = center ? {display: 'flex', justifyContent: 'center'} : {}; 38 | return ( 39 | 40 |

41 |

{ 45 | ref(e); 46 | resizeCallback(e); 47 | }}> 48 | {title} 49 |

50 |
51 | 52 | ); 53 | } 54 | -------------------------------------------------------------------------------- /src/client/theme/SiteTheme.tsx: -------------------------------------------------------------------------------- 1 | import {GlobalStyles, ThemeProvider} from "@mui/system"; 2 | import {createTheme, CssBaseline} from "@mui/material"; 3 | import type { PropsWithChildren } from "react"; 4 | import {Work_Sans} from "@next/font/google"; 5 | 6 | // Initialize font. This must be assigned to a variable in order to build. 7 | // noinspection JSUnusedLocalSymbols 8 | const font = Work_Sans({ 9 | subsets: ['latin'], 10 | display: 'swap', 11 | variable: '--worksans-font', 12 | }); 13 | 14 | const darkTheme = createTheme({ 15 | palette: { 16 | mode: "dark", 17 | }, 18 | typography: { 19 | fontFamily: 'var(--worksans-font)', 20 | }, 21 | transitions: { 22 | create: () => 'none', 23 | }, 24 | components: { 25 | MuiTooltip: { 26 | styleOverrides: { 27 | popper: { 28 | pointerEvents: 'none', 29 | }, 30 | tooltip: { 31 | backgroundColor: 'rgba(39, 39, 42, .90)', 32 | fontSize: '.9rem', 33 | }, 34 | }, 35 | }, 36 | }, 37 | }); 38 | 39 | export function SiteTheme(props: PropsWithChildren<{}>) { 40 | return ( 41 | 42 | 43 | 49 | {props.children} 50 | 51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /src/env/client.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import { clientEnv, clientSchema } from "./schema.mjs"; 3 | 4 | const _clientEnv = clientSchema.safeParse(clientEnv); 5 | 6 | export const formatErrors = ( 7 | /** @type {import('zod').ZodFormattedError,string>} */ 8 | errors, 9 | ) => 10 | Object.entries(errors) 11 | .map(([name, value]) => { 12 | if (value && "_errors" in value) 13 | return `${name}: ${value._errors.join(", ")}\n`; 14 | }) 15 | .filter(Boolean); 16 | 17 | if (!_clientEnv.success) { 18 | console.error( 19 | "❌ Invalid environment variables:\n", 20 | ...formatErrors(_clientEnv.error.format()), 21 | ); 22 | throw new Error("Invalid environment variables"); 23 | } 24 | 25 | for (let key of Object.keys(_clientEnv.data)) { 26 | if (!key.startsWith("NEXT_PUBLIC_")) { 27 | console.warn( 28 | `❌ Invalid public environment variable name: ${key}. It must begin with 'NEXT_PUBLIC_'`, 29 | ); 30 | 31 | throw new Error("Invalid public environment variable name"); 32 | } 33 | } 34 | 35 | export const env = _clientEnv.data; 36 | -------------------------------------------------------------------------------- /src/env/schema.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import { z } from "zod"; 3 | 4 | /** 5 | * Specify your server-side environment variables schema here. 6 | * This way you can ensure the app isn't built with invalid env vars. 7 | */ 8 | export const serverSchema = z.object({ 9 | DATABASE_URL: z.string().url(), 10 | NODE_ENV: z.enum(["development", "test", "production"]), 11 | NEXTAUTH_SECRET: 12 | process.env.NODE_ENV === "production" 13 | ? z.string().min(1) 14 | : z.string().min(1).optional(), 15 | NEXTAUTH_URL: z.preprocess( 16 | // This makes Vercel deployments not fail if you don't set NEXTAUTH_URL 17 | // Since NextAuth automatically uses the VERCEL_URL if present. 18 | (str) => process.env.VERCEL_URL ?? str, 19 | // VERCEL_URL doesnt include `https` so it cant be validated as a URL 20 | process.env.VERCEL ? z.string() : z.string().url(), 21 | ), 22 | TWITCH_CLIENT_ID: z.string(), 23 | TWITCH_CLIENT_SECRET: z.string(), 24 | UPSTASH_REDIS_REST_URL: z.string(), 25 | UPSTASH_REDIS_REST_TOKEN: z.string(), 26 | }); 27 | 28 | /** 29 | * Specify your client-side environment variables schema here. 30 | * This way you can ensure the app isn't built with invalid env vars. 31 | * To expose them to the client, prefix them with `NEXT_PUBLIC_`. 32 | */ 33 | export const clientSchema = z.object({ 34 | // NEXT_PUBLIC_BAR: z.string(), 35 | }); 36 | 37 | /** 38 | * You can't destruct `process.env` as a regular object, so you have to do 39 | * it manually here. This is because Next.js evaluates this at build time, 40 | * and only used environment variables are included in the build. 41 | * @type {{ [k in keyof z.infer]: z.infer[k] | undefined }} 42 | */ 43 | export const clientEnv = { 44 | // NEXT_PUBLIC_BAR: process.env.NEXT_PUBLIC_BAR, 45 | }; 46 | -------------------------------------------------------------------------------- /src/env/server.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | /** 3 | * This file is included in `/next.config.mjs` which ensures the app isn't built with invalid env vars. 4 | * It has to be a `.mjs`-file to be imported there. 5 | */ 6 | import { serverSchema } from "./schema.mjs"; 7 | import { env as clientEnv, formatErrors } from "./client.mjs"; 8 | 9 | const _serverEnv = serverSchema.safeParse(process.env); 10 | 11 | if (!_serverEnv.success) { 12 | console.error( 13 | "❌ Invalid environment variables:\n", 14 | ...formatErrors(_serverEnv.error.format()), 15 | ); 16 | throw new Error("Invalid environment variables"); 17 | } 18 | 19 | for (let key of Object.keys(_serverEnv.data)) { 20 | if (key.startsWith("NEXT_PUBLIC_")) { 21 | console.warn("❌ You are exposing a server-side env-variable:", key); 22 | 23 | throw new Error("You are exposing a server-side env-variable"); 24 | } 25 | } 26 | 27 | export const env = { ..._serverEnv.data, ...clientEnv }; 28 | -------------------------------------------------------------------------------- /src/pages/_app.css: -------------------------------------------------------------------------------- 1 | @tailwind components; 2 | @tailwind utilities; 3 | 4 | body { 5 | @apply bg-zinc-900 text-zinc-100; 6 | 7 | /* Kludge: This fixes a bug? in Firefox and Chrome where undefined variables in CSS break the filter for tailwind. */ 8 | --noop-filter: opacity(100%); 9 | --tw-blur: var(--noop-filter); 10 | --tw-brightness: var(--noop-filter); 11 | --tw-contrast: var(--noop-filter); 12 | --tw-grayscale: var(--noop-filter); 13 | --tw-hue-rotate: var(--noop-filter); 14 | --tw-invert: var(--noop-filter); 15 | --tw-saturate: var(--noop-filter); 16 | --tw-sepia: var(--noop-filter); 17 | --tw-drop-shadow: var(--noop-filter); 18 | } 19 | 20 | path { 21 | @apply fill-current !important; 22 | } 23 | -------------------------------------------------------------------------------- /src/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import './_app.css'; 2 | import { type AppType } from "next/app"; 3 | import { type Session } from "next-auth"; 4 | import { SessionProvider } from "next-auth/react"; 5 | 6 | import { trpc } from "../utils/trpc"; 7 | import Head from "next/head"; 8 | import { SiteTheme } from "../client/theme/SiteTheme"; 9 | import { MainContainer } from "../client/container/MainContainer"; 10 | import { Work_Sans } from "@next/font/google"; 11 | 12 | const workSans = Work_Sans({ subsets: ["latin"], display: "swap" }); 13 | 14 | const MyApp: AppType<{ session: Session | null }> = ({ 15 | Component, 16 | pageProps: { session, ...pageProps }, 17 | }) => { 18 | return ( 19 | 20 | 21 | Acorn1010 22 | 26 | 27 | 28 |
29 | 30 | 31 | 32 | 33 | 34 |
35 |
36 | ); 37 | }; 38 | 39 | export default trpc.withTRPC(MyApp); 40 | -------------------------------------------------------------------------------- /src/pages/api/auth/[...nextauth].ts: -------------------------------------------------------------------------------- 1 | import NextAuth, { type NextAuthOptions } from "next-auth"; 2 | import TwitchProvider from "next-auth/providers/twitch"; 3 | // Prisma adapter for NextAuth, optional and can be removed 4 | import { PrismaAdapter } from "@next-auth/prisma-adapter"; 5 | 6 | import { env } from "../../../env/server.mjs"; 7 | import { prisma } from "../../../server/db/client"; 8 | 9 | export const authOptions: NextAuthOptions = { 10 | // Include user.id on session 11 | callbacks: { 12 | session({ session, user }) { 13 | if (session.user) { 14 | session.user.id = user.id; 15 | } 16 | return session; 17 | }, 18 | }, 19 | // Configure one or more authentication providers 20 | adapter: PrismaAdapter(prisma), 21 | providers: [ 22 | TwitchProvider({ 23 | clientId: env.TWITCH_CLIENT_ID, 24 | clientSecret: env.TWITCH_CLIENT_SECRET, 25 | }), 26 | ], 27 | }; 28 | 29 | export default NextAuth(authOptions); 30 | -------------------------------------------------------------------------------- /src/pages/api/restricted.ts: -------------------------------------------------------------------------------- 1 | import { type NextApiRequest, type NextApiResponse } from "next"; 2 | 3 | import { getServerAuthSession } from "../../server/common/get-server-auth-session"; 4 | 5 | const restricted = async (req: NextApiRequest, res: NextApiResponse) => { 6 | const session = await getServerAuthSession({ req, res }); 7 | 8 | if (session) { 9 | res.send({ 10 | content: 11 | "This is protected content. You can access this content because you are signed in.", 12 | }); 13 | } else { 14 | res.send({ 15 | error: 16 | "You must be signed in to view the protected content on this page.", 17 | }); 18 | } 19 | }; 20 | 21 | export default restricted; 22 | -------------------------------------------------------------------------------- /src/pages/api/trpc/[trpc].ts: -------------------------------------------------------------------------------- 1 | import { createNextApiHandler } from "@trpc/server/adapters/next"; 2 | 3 | import { env } from "../../../env/server.mjs"; 4 | import { createContext } from "../../../server/trpc/context"; 5 | import { appRouter } from "../../../server/trpc/router/_app"; 6 | 7 | // export API handler 8 | export default createNextApiHandler({ 9 | router: appRouter, 10 | createContext, 11 | onError: 12 | env.NODE_ENV === "development" 13 | ? ({ path, error }) => { 14 | console.error(`❌ tRPC failed on ${path}: ${error}`); 15 | } 16 | : undefined, 17 | }); 18 | -------------------------------------------------------------------------------- /src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import { Container } from "@mui/material"; 2 | import Head from "next/head"; 3 | import {TwitchClipsGrid} from "../client/clips/TwitchClipsGrid"; 4 | import {useEffect, useState} from "react"; 5 | 6 | export default function Home() { 7 | return ( 8 | <> 9 | 10 | Acorn1010 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | ); 21 | } 22 | 23 | /** Displays the Kick livestream if currently live. */ 24 | function KickStream() { 25 | const livestream = useKickLivestream('acorn1010'); 26 | 27 | if (!livestream) { 28 | return null; 29 | } 30 | 31 | const src = 'https://kick.com/acorn1010'; 32 | return ( 33 |
34 |

🔴 I'M LIVE! - ${livestream.session_title}

35 |