├── tsconfig.json ├── packages ├── common │ ├── src │ │ ├── utils │ │ │ └── reactions.ts │ │ ├── locales │ │ │ ├── index.ts │ │ │ └── en │ │ │ │ └── common.json │ │ ├── hooks │ │ │ ├── useDebounce.ts │ │ │ └── useEvent.ts │ │ └── services │ │ │ ├── useQuestionSubscription.ts │ │ │ ├── useVoteSubscription.ts │ │ │ ├── useAnswerQuestionMutation.ts │ │ │ ├── useDeleteQuestionMutation.ts │ │ │ ├── useToggleVoteMutation.ts │ │ │ └── SessionService.tsx │ └── package.json └── api │ ├── package.json │ └── src │ ├── routes │ ├── index.ts │ ├── member.ts │ ├── vote.ts │ ├── room.ts │ └── question.ts │ ├── trpc.ts │ ├── context.ts │ └── types.ts ├── apps ├── expo │ ├── assets │ │ ├── icon.png │ │ ├── splash.png │ │ ├── favicon.png │ │ ├── adaptive-icon.png │ │ ├── icon.staging.png │ │ └── icon.development.png │ ├── .expo-shared │ │ └── assets.json │ ├── metro.config.js │ ├── .babelrc.js │ ├── index.js │ ├── utils │ │ ├── i18next.ts │ │ ├── supabase.ts │ │ ├── trpc.ts │ │ └── locales │ │ │ └── en.ts │ ├── eas.json │ ├── components │ │ └── ErrorMessage │ │ │ └── ErrorMessage.tsx │ ├── tsconfig.json │ ├── routes │ │ ├── Account │ │ │ └── Account.tsx │ │ ├── Room │ │ │ ├── Room.tsx │ │ │ ├── AddQuestion │ │ │ │ ├── AddQuestion.tsx │ │ │ │ └── AddQuestionForm │ │ │ │ │ └── AddQuestionForm.tsx │ │ │ ├── Questions │ │ │ │ ├── QuestionsItem │ │ │ │ │ ├── DeleteAction │ │ │ │ │ │ └── DeleteAction.tsx │ │ │ │ │ ├── AnsweredActions │ │ │ │ │ │ └── AnsweredActions.tsx │ │ │ │ │ ├── ReactionButton │ │ │ │ │ │ └── ReactionButton.tsx │ │ │ │ │ └── QuestionsItem.tsx │ │ │ │ └── Questions.tsx │ │ │ └── RoomHeading │ │ │ │ ├── RoomActions │ │ │ │ ├── DeleteRoomAction │ │ │ │ │ └── DeleteRoomAction.tsx │ │ │ │ └── RoomActions.tsx │ │ │ │ └── RoomHeading.tsx │ │ ├── Rooms │ │ │ ├── RoomsItem │ │ │ │ └── RoomsItem.tsx │ │ │ ├── Rooms.tsx │ │ │ └── AddRoom │ │ │ │ └── AddRoom.tsx │ │ ├── RoomSettings │ │ │ ├── RoomSettings.tsx │ │ │ └── EditRoom │ │ │ │ └── EditRoom.tsx │ │ ├── Router.tsx │ │ ├── SendLink │ │ │ └── SendLink.tsx │ │ ├── SignIn │ │ │ └── SignIn.tsx │ │ └── SignUp │ │ │ └── SignUp.tsx │ ├── .gitignore │ ├── App.tsx │ ├── app.config.js │ ├── modules │ │ └── RoomForm │ │ │ └── RoomForm.tsx │ └── package.json └── next │ ├── public │ ├── favicon.ico │ └── vercel.svg │ ├── postcss.config.js │ ├── components │ ├── Loader │ │ └── Loader.tsx │ ├── ErrorMessage │ │ └── ErrorMessage.tsx │ ├── Pagination │ │ └── Pagination.tsx │ └── Toast │ │ └── Toast.tsx │ ├── utils │ ├── supabase.ts │ ├── withTranslations.ts │ ├── paths.ts │ └── trpc.ts │ ├── next-i18next.config.js │ ├── next.config.js │ ├── tailwind.config.js │ ├── styles │ ├── globals.css │ └── Home.module.css │ ├── .gitignore │ ├── tsconfig.json │ ├── pages │ ├── api │ │ └── trpc │ │ │ └── [trpc].ts │ ├── _app.tsx │ ├── login.tsx │ ├── index.tsx │ └── room │ │ └── [id] │ │ ├── settings.tsx │ │ └── index.tsx │ ├── modules │ ├── Rooms │ │ ├── RoomsItem │ │ │ └── RoomsItem.tsx │ │ └── Rooms.tsx │ ├── RoomHeading │ │ └── RoomHeading.tsx │ ├── Navbar │ │ ├── Logout │ │ │ └── Logout.tsx │ │ └── Navbar.tsx │ ├── Questions │ │ ├── QuestionsItem │ │ │ ├── QuestionMenu │ │ │ │ ├── AnswerAction │ │ │ │ │ └── AnswerAction.tsx │ │ │ │ ├── QuestionMenu.tsx │ │ │ │ └── DeleteQuestion │ │ │ │ │ └── DeleteQuestion.tsx │ │ │ ├── ReactionButton │ │ │ │ └── ReactionButton.tsx │ │ │ └── QuestionsItem.tsx │ │ └── Questions.tsx │ ├── AddRoom │ │ └── AddRoom.tsx │ ├── SendMagicLink │ │ └── SendMagicLink.tsx │ ├── AddQuestion │ │ └── AddQuestion.tsx │ ├── RoomForm │ │ └── RoomForm.tsx │ └── SignIn │ │ └── SignIn.tsx │ ├── README.md │ └── package.json ├── prisma ├── migrations │ ├── 20220201105846_drop_unique │ │ └── migration.sql │ ├── migration_lock.toml │ ├── 20220111230540_prisma_3 │ │ └── migration.sql │ └── 20210910132122_init │ │ └── migration.sql └── schema.prisma ├── .vscode └── settings.json ├── tsconfig.base.json ├── readme.md ├── .eslintrc ├── .gitignore └── package.json /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base" 3 | } -------------------------------------------------------------------------------- /packages/common/src/utils/reactions.ts: -------------------------------------------------------------------------------- 1 | export const reactions = ['👍', '👎', '❤️', '🙌', '👀', '😄', '😠']; 2 | -------------------------------------------------------------------------------- /apps/expo/assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wmalarski/trpc-expo-kitchen-sink/HEAD/apps/expo/assets/icon.png -------------------------------------------------------------------------------- /apps/expo/assets/splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wmalarski/trpc-expo-kitchen-sink/HEAD/apps/expo/assets/splash.png -------------------------------------------------------------------------------- /apps/expo/assets/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wmalarski/trpc-expo-kitchen-sink/HEAD/apps/expo/assets/favicon.png -------------------------------------------------------------------------------- /apps/next/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wmalarski/trpc-expo-kitchen-sink/HEAD/apps/next/public/favicon.ico -------------------------------------------------------------------------------- /apps/expo/assets/adaptive-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wmalarski/trpc-expo-kitchen-sink/HEAD/apps/expo/assets/adaptive-icon.png -------------------------------------------------------------------------------- /apps/expo/assets/icon.staging.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wmalarski/trpc-expo-kitchen-sink/HEAD/apps/expo/assets/icon.staging.png -------------------------------------------------------------------------------- /apps/next/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /apps/expo/assets/icon.development.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wmalarski/trpc-expo-kitchen-sink/HEAD/apps/expo/assets/icon.development.png -------------------------------------------------------------------------------- /packages/common/src/locales/index.ts: -------------------------------------------------------------------------------- 1 | export const resources = { 2 | common: { 3 | en: require('./en/common.json'), 4 | }, 5 | }; 6 | -------------------------------------------------------------------------------- /prisma/migrations/20220201105846_drop_unique/migration.sql: -------------------------------------------------------------------------------- 1 | -- DropIndex 2 | DROP INDEX "Post_createdAt_key"; 3 | 4 | -- DropIndex 5 | DROP INDEX "Post_updatedAt_key"; 6 | -------------------------------------------------------------------------------- /prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "postgresql" -------------------------------------------------------------------------------- /apps/expo/.expo-shared/assets.json: -------------------------------------------------------------------------------- 1 | { 2 | "12bb71342c6255bbf50437ec8f4441c083f47cdb74bd89160c15e4f43e52a1cb": true, 3 | "40b842e832070c58deac6aa9e08fa459302ee3f9da492c7e77d93d2fbf4a56fd": true 4 | } 5 | -------------------------------------------------------------------------------- /apps/expo/metro.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | const { createMetroConfiguration } = require('expo-yarn-workspaces'); 3 | 4 | module.exports = createMetroConfiguration(__dirname); 5 | -------------------------------------------------------------------------------- /prisma/migrations/20220111230540_prisma_3/migration.sql: -------------------------------------------------------------------------------- 1 | -- RenameIndex 2 | ALTER INDEX "Post.createdAt_unique" RENAME TO "Post_createdAt_key"; 3 | 4 | -- RenameIndex 5 | ALTER INDEX "Post.updatedAt_unique" RENAME TO "Post_updatedAt_key"; 6 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": [ 3 | "Actionsheet", 4 | "clsx", 5 | "daisyui", 6 | "hookform", 7 | "Lngs", 8 | "manypkg", 9 | "predev", 10 | "Pressable", 11 | "supabase", 12 | "superjson", 13 | "tailwindcss", 14 | "trpc" 15 | ] 16 | } -------------------------------------------------------------------------------- /packages/api/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@tens/api", 3 | "version": "0.0.1", 4 | "author": "w.malarski@gmail.com", 5 | "license": "MIT", 6 | "private": true, 7 | "files": [ 8 | "src" 9 | ], 10 | "scripts": {}, 11 | "dependencies": {}, 12 | "devDependencies": {} 13 | } 14 | -------------------------------------------------------------------------------- /apps/next/components/Loader/Loader.tsx: -------------------------------------------------------------------------------- 1 | import { useTranslation } from 'next-i18next'; 2 | import { ReactElement } from 'react'; 3 | 4 | export const Loader = (): ReactElement => { 5 | const { t } = useTranslation('common', { keyPrefix: 'Loader' }); 6 | 7 | return {t('loading')}; 8 | }; 9 | -------------------------------------------------------------------------------- /apps/next/utils/supabase.ts: -------------------------------------------------------------------------------- 1 | import { createClient } from '@supabase/supabase-js'; 2 | 3 | const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || ''; 4 | const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || ''; 5 | 6 | export const supabase = createClient(supabaseUrl, supabaseAnonKey); 7 | -------------------------------------------------------------------------------- /packages/common/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@tens/common", 3 | "version": "0.0.1", 4 | "author": "w.malarski@gmail.com", 5 | "license": "MIT", 6 | "private": true, 7 | "files": [ 8 | "src" 9 | ], 10 | "scripts": {}, 11 | "dependencies": {}, 12 | "devDependencies": {} 13 | } 14 | -------------------------------------------------------------------------------- /apps/next/next-i18next.config.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/no-var-requires 2 | const path = require('path'); 3 | 4 | module.exports = { 5 | i18n: { 6 | defaultLocale: 'en', 7 | locales: ['en'], 8 | }, 9 | localePath: path.resolve('../../packages/common/src/locales'), 10 | }; 11 | -------------------------------------------------------------------------------- /apps/next/next.config.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/no-var-requires 2 | const { i18n } = require('./next-i18next.config'); 3 | 4 | const nextConfig = { 5 | i18n, 6 | reactStrictMode: true, 7 | experimental: { 8 | externalDir: true, 9 | }, 10 | }; 11 | 12 | module.exports = nextConfig; 13 | -------------------------------------------------------------------------------- /apps/expo/.babelrc.js: -------------------------------------------------------------------------------- 1 | module.exports = function (api) { 2 | api.cache(true); 3 | 4 | return { 5 | presets: [['babel-preset-expo', { jsxRuntime: 'automatic' }]], 6 | plugins: [ 7 | 'react-native-reanimated/plugin', 8 | '@babel/plugin-proposal-unicode-property-regex', 9 | ], 10 | }; 11 | }; 12 | -------------------------------------------------------------------------------- /apps/next/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: [ 4 | './pages/**/*.{js,ts,jsx,tsx}', 5 | './components/**/*.{js,ts,jsx,tsx}', 6 | './modules/**/*.{js,ts,jsx,tsx}', 7 | ], 8 | theme: { 9 | extend: {}, 10 | }, 11 | plugins: [require('daisyui')], 12 | }; 13 | -------------------------------------------------------------------------------- /tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "strictNullChecks": true, 5 | "esModuleInterop": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "noUnusedLocals": true, 9 | "skipLibCheck": true, 10 | "sourceMap": true, 11 | "jsx": "react" 12 | } 13 | } -------------------------------------------------------------------------------- /apps/expo/index.js: -------------------------------------------------------------------------------- 1 | // @generated by expo-yarn-workspaces 2 | 3 | import 'expo/build/Expo.fx'; 4 | import { activateKeepAwake } from 'expo-keep-awake'; 5 | import registerRootComponent from 'expo/build/launch/registerRootComponent'; 6 | 7 | import App from './App'; 8 | 9 | if (__DEV__) { 10 | activateKeepAwake(); 11 | } 12 | 13 | registerRootComponent(App); 14 | -------------------------------------------------------------------------------- /apps/expo/utils/i18next.ts: -------------------------------------------------------------------------------- 1 | import { resources } from '@tens/common/src/locales'; 2 | import i18n from 'i18next'; 3 | import { initReactI18next } from 'react-i18next'; 4 | 5 | const supportedLngs = ['en']; 6 | 7 | i18n.use(initReactI18next).init({ 8 | compatibilityJSON: 'v3', 9 | resources, 10 | fallbackLng: 'en', 11 | supportedLngs, 12 | interpolation: { 13 | escapeValue: false, 14 | }, 15 | }); 16 | 17 | export default i18n; 18 | -------------------------------------------------------------------------------- /apps/next/styles/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | html, 6 | body { 7 | padding: 0; 8 | margin: 0; 9 | font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, 10 | Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif; 11 | } 12 | 13 | a { 14 | color: inherit; 15 | text-decoration: none; 16 | } 17 | 18 | * { 19 | box-sizing: border-box; 20 | } 21 | -------------------------------------------------------------------------------- /packages/api/src/routes/index.ts: -------------------------------------------------------------------------------- 1 | import { t } from '../trpc'; 2 | import { memberRouter } from './member'; 3 | import { questionRouter } from './question'; 4 | import { roomRouter } from './room'; 5 | import { voteRouter } from './vote'; 6 | 7 | export const appRouter = t.router({ 8 | question: questionRouter, 9 | member: memberRouter, 10 | room: roomRouter, 11 | vote: voteRouter, 12 | }); 13 | 14 | export type AppRouter = typeof appRouter; 15 | -------------------------------------------------------------------------------- /packages/common/src/hooks/useDebounce.ts: -------------------------------------------------------------------------------- 1 | import type { DebouncedFunc } from 'lodash'; 2 | import debounce from 'lodash.debounce'; 3 | import { useMemo } from 'react'; 4 | import { useEvent } from './useEvent'; 5 | 6 | export const useDebounce = ( 7 | callback: (arg: T) => R, 8 | delay: number, 9 | ): DebouncedFunc<(arg: T) => R> => { 10 | const callbackMemoized = useEvent(callback); 11 | 12 | return useMemo( 13 | () => debounce(callbackMemoized, delay), 14 | [callbackMemoized, delay], 15 | ); 16 | }; 17 | -------------------------------------------------------------------------------- /packages/common/src/hooks/useEvent.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useMemo, useRef } from 'react'; 2 | 3 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 4 | export const useEvent = any>( 5 | callback: T | undefined, 6 | ): T => { 7 | const callbackRef = useRef(callback); 8 | 9 | useEffect(() => { 10 | callbackRef.current = callback; 11 | }); 12 | 13 | // https://github.com/facebook/react/issues/19240 14 | return useMemo(() => ((...args) => callbackRef.current?.(...args)) as T, []); 15 | }; 16 | -------------------------------------------------------------------------------- /apps/expo/utils/supabase.ts: -------------------------------------------------------------------------------- 1 | import AsyncStorage from '@react-native-async-storage/async-storage'; 2 | import { createClient } from '@supabase/supabase-js'; 3 | import Constants from 'expo-constants'; 4 | 5 | const supabaseUrl = Constants.manifest.extra.supabaseUrl; 6 | const supabaseAnonKey = Constants.manifest.extra.supabaseAnonKey; 7 | 8 | export const supabase = createClient(supabaseUrl, supabaseAnonKey, { 9 | localStorage: AsyncStorage as any, 10 | autoRefreshToken: true, 11 | persistSession: true, 12 | detectSessionInUrl: false, 13 | }); 14 | -------------------------------------------------------------------------------- /prisma/migrations/20210910132122_init/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "Post" ( 3 | "id" TEXT NOT NULL, 4 | "title" TEXT NOT NULL, 5 | "text" TEXT NOT NULL, 6 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 7 | "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 8 | 9 | PRIMARY KEY ("id") 10 | ); 11 | 12 | -- CreateIndex 13 | CREATE UNIQUE INDEX "Post.createdAt_unique" ON "Post"("createdAt"); 14 | 15 | -- CreateIndex 16 | CREATE UNIQUE INDEX "Post.updatedAt_unique" ON "Post"("updatedAt"); 17 | -------------------------------------------------------------------------------- /packages/api/src/trpc.ts: -------------------------------------------------------------------------------- 1 | import { initTRPC, TRPCError } from '@trpc/server'; 2 | import superjson from 'superjson'; 3 | import { Context } from './context'; 4 | 5 | export const t = initTRPC<{ 6 | ctx: Context; 7 | }>()({ 8 | transformer: superjson, 9 | errorFormatter({ shape }) { 10 | return shape; 11 | }, 12 | }); 13 | 14 | export const protectedProcedure = t.procedure.use(({ ctx, next }) => { 15 | if (!ctx.user) { 16 | throw new TRPCError({ code: 'UNAUTHORIZED' }); 17 | } 18 | return next({ ctx: { ...ctx, user: ctx.user } }); 19 | }); 20 | -------------------------------------------------------------------------------- /apps/next/.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*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | .env -------------------------------------------------------------------------------- /apps/next/components/ErrorMessage/ErrorMessage.tsx: -------------------------------------------------------------------------------- 1 | import { useTranslation } from 'next-i18next'; 2 | import { ReactElement } from 'react'; 3 | 4 | type Props = { 5 | message: string; 6 | onReloadClick: () => void; 7 | }; 8 | 9 | export const ErrorMessage = ({ 10 | message, 11 | onReloadClick, 12 | }: Props): ReactElement => { 13 | const { t } = useTranslation('common', { keyPrefix: 'ErrorMessage' }); 14 | return ( 15 |
16 | {message} 17 | 18 |
19 | ); 20 | }; 21 | -------------------------------------------------------------------------------- /apps/expo/eas.json: -------------------------------------------------------------------------------- 1 | { 2 | "build": { 3 | "release": { 4 | "releaseChannel": "production", 5 | "env": { 6 | "STAGE": "production" 7 | } 8 | }, 9 | "preview": { 10 | "releaseChannel": "staging", 11 | "distribution": "internal", 12 | "android": { 13 | "buildType": "apk" 14 | }, 15 | "env": { 16 | "STAGE": "staging" 17 | } 18 | }, 19 | "development": { 20 | "developmentClient": true, 21 | "distribution": "internal", 22 | "env": { 23 | "STAGE": "development" 24 | } 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /apps/next/utils/withTranslations.ts: -------------------------------------------------------------------------------- 1 | import type { GetServerSideProps } from 'next'; 2 | import { serverSideTranslations } from 'next-i18next/serverSideTranslations'; 3 | 4 | export const withTranslations = ( 5 | next: GetServerSideProps = () => Promise.resolve({ props: {} }), 6 | ): GetServerSideProps => { 7 | return async (context) => { 8 | const current = await next(context); 9 | 10 | return { 11 | ...current, 12 | props: { 13 | ...('props' in current ? current.props : {}), 14 | ...(await serverSideTranslations(context.locale || 'en', ['common'])), 15 | }, 16 | }; 17 | }; 18 | }; 19 | -------------------------------------------------------------------------------- /apps/expo/components/ErrorMessage/ErrorMessage.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Text, VStack } from 'native-base'; 2 | import { ReactElement } from 'react'; 3 | import { useTranslation } from 'react-i18next'; 4 | 5 | type Props = { 6 | message: string; 7 | onReloadPress: () => void; 8 | }; 9 | 10 | export const ErrorMessage = ({ 11 | message, 12 | onReloadPress, 13 | }: Props): ReactElement => { 14 | const { t } = useTranslation('common', { keyPrefix: 'ErrorMessage' }); 15 | 16 | return ( 17 | 18 | {message} 19 | 20 | 21 | ); 22 | }; 23 | -------------------------------------------------------------------------------- /apps/expo/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": true, 4 | "jsx": "preserve", 5 | "lib": [ 6 | "dom", 7 | "esnext" 8 | ], 9 | "moduleResolution": "node", 10 | "noEmit": true, 11 | "skipLibCheck": true, 12 | "resolveJsonModule": true, 13 | "strict": true, 14 | "target": "esnext", 15 | "allowJs": true, 16 | "forceConsistentCasingInFileNames": true, 17 | "esModuleInterop": true, 18 | "module": "esnext", 19 | "isolatedModules": true 20 | }, 21 | "include": [ 22 | "next-env.d.ts", 23 | "**/*.ts", 24 | "**/*.tsx" 25 | ], 26 | "exclude": [ 27 | "node_modules" 28 | ], 29 | "extends": "expo/tsconfig.base" 30 | } -------------------------------------------------------------------------------- /apps/next/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base", 3 | "compilerOptions": { 4 | "baseUrl": "src", 5 | "target": "es5", 6 | "lib": [ 7 | "dom", 8 | "dom.iterable", 9 | "esnext" 10 | ], 11 | "allowJs": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "noEmit": true, 14 | "module": "esnext", 15 | "moduleResolution": "node", 16 | "resolveJsonModule": true, 17 | "isolatedModules": true, 18 | "jsx": "preserve", 19 | "incremental": true 20 | }, 21 | "include": [ 22 | "next-env.d.ts", 23 | "**/*.ts", 24 | "**/*.tsx", 25 | "../../packages/common/src/services/SessionService.tsx" 26 | ], 27 | "exclude": [ 28 | "node_modules" 29 | ] 30 | } -------------------------------------------------------------------------------- /packages/api/src/routes/member.ts: -------------------------------------------------------------------------------- 1 | import { TRPCError } from '@trpc/server'; 2 | import { z } from 'zod'; 3 | import { t } from '../trpc'; 4 | 5 | export const memberRouter = t.router({ 6 | add: t.procedure 7 | .input( 8 | z.object({ 9 | roomId: z.string().uuid(), 10 | email: z.string().email(), 11 | redirectTo: z.string().url(), 12 | }), 13 | ) 14 | .mutation(async ({ ctx, input }) => { 15 | const { data, error } = await ctx.supabase.auth.api.inviteUserByEmail( 16 | input.email, 17 | { redirectTo: input.redirectTo }, 18 | ); 19 | 20 | if (error || !data) { 21 | throw new TRPCError({ code: 'BAD_REQUEST' }); 22 | } 23 | 24 | return ctx.prisma.member.create({ 25 | data: { userId: data.id, roomId: input.roomId }, 26 | }); 27 | }), 28 | }); 29 | -------------------------------------------------------------------------------- /apps/expo/utils/trpc.ts: -------------------------------------------------------------------------------- 1 | import type { AppRouter } from '@tens/api/src/routes'; 2 | import { createReactQueryHooks } from '@trpc/react'; 3 | import Constants from 'expo-constants'; 4 | import superjson from 'superjson'; 5 | import { supabase } from './supabase'; 6 | 7 | const { manifest } = Constants; 8 | 9 | export const trpc = createReactQueryHooks(); 10 | 11 | export const transformer = superjson; 12 | 13 | export const createTrpcClient = () => { 14 | const localhost = `http://${manifest?.debuggerHost?.split(':').shift()}:3000`; 15 | 16 | return trpc.createClient({ 17 | url: `${localhost}/api/trpc`, 18 | 19 | headers() { 20 | const session = supabase.auth.session(); 21 | if (!session) return { Authorization: [] }; 22 | return { Authorization: `Bearer ${session.access_token}` }; 23 | }, 24 | transformer, 25 | }); 26 | }; 27 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Learning tRPC + Expo + Next.js + Supabase = TENS Stack 2 | 3 | ## 🚀 Introduction 4 | 5 | A monorepo containing: 6 | 7 | - Next.js web app 8 | - React Native app with Expo 9 | - A [tRPC](https://trpc.io)-API which is inferred straight into the above. 10 | - [Prisma](http://prisma.io/) as a typesafe ORM 11 | 12 | > In tRPC you simply write API-functions that are automatically inferred straight into your frontend - no matter if it's React, React Native, or something else _(that is TS/JS-based)_. 13 | 14 | ## 📁 Folder structure 15 | 16 | ```graphql 17 | . 18 | ├── apps 19 | │ ├── expo # Expo/RN application 20 | │ └── next # Server-side rendered Next.js application 21 | ├── packages 22 | │ └── api # tRPC API 23 | └── prisma # Prisma setup 24 | ``` 25 | 26 | ## 🔗 Credits 27 | 28 | - [https://github.com/trpc/zart](zart) base template. 29 | - [https://github.com/t3-oss/create-t3-app](create-t3-app) 30 | -------------------------------------------------------------------------------- /apps/next/utils/paths.ts: -------------------------------------------------------------------------------- 1 | import { useSessionStatus } from '@tens/common/src/services/SessionService'; 2 | import { useRouter } from 'next/router'; 3 | import { useEffect } from 'react'; 4 | 5 | export const paths = { 6 | index: '/', 7 | login: '/login', 8 | room: (id: string) => `/room/${id}`, 9 | settings: (id: string) => `/room/${id}/settings`, 10 | }; 11 | 12 | export const usePublicPath = () => { 13 | const router = useRouter(); 14 | 15 | const sessionStatus = useSessionStatus(); 16 | 17 | useEffect(() => { 18 | if (sessionStatus !== 'auth') return; 19 | router.push(paths.index); 20 | }, [sessionStatus, router]); 21 | }; 22 | 23 | export const useProtectedPath = () => { 24 | const router = useRouter(); 25 | 26 | const sessionStatus = useSessionStatus(); 27 | 28 | useEffect(() => { 29 | if (sessionStatus !== 'anon') return; 30 | router.push(paths.login); 31 | }, [sessionStatus, router]); 32 | }; 33 | -------------------------------------------------------------------------------- /apps/next/pages/api/trpc/[trpc].ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This file contains tRPC's HTTP response handler 3 | */ 4 | import { createContext } from '@tens/api/src/context'; 5 | import { appRouter } from '@tens/api/src/routes'; 6 | import * as trpcNext from '@trpc/server/adapters/next'; 7 | 8 | export default trpcNext.createNextApiHandler({ 9 | router: appRouter, 10 | /** 11 | * @link https://trpc.io/docs/context 12 | */ 13 | createContext, 14 | /** 15 | * @link https://trpc.io/docs/error-handling 16 | */ 17 | onError({ error }) { 18 | if (error.code === 'INTERNAL_SERVER_ERROR') { 19 | // send to bug reporting 20 | console.error('Something went wrong', error); 21 | } 22 | }, 23 | /** 24 | * Enable query batching 25 | */ 26 | batching: { 27 | enabled: true, 28 | }, 29 | /** 30 | * @link https://trpc.io/docs/caching#api-response-caching 31 | */ 32 | // responseMeta() { 33 | // // ... 34 | // }, 35 | }); 36 | -------------------------------------------------------------------------------- /apps/next/modules/Rooms/RoomsItem/RoomsItem.tsx: -------------------------------------------------------------------------------- 1 | import { Room } from '@prisma/client'; 2 | import { paths } from '@tens/next/utils/paths'; 3 | import { useTranslation } from 'next-i18next'; 4 | import Link from 'next/link'; 5 | import { ReactElement } from 'react'; 6 | 7 | type Props = { 8 | room: Room; 9 | }; 10 | 11 | export const RoomsItem = ({ room }: Props): ReactElement => { 12 | const { t } = useTranslation('common', { keyPrefix: 'Rooms.List' }); 13 | 14 | return ( 15 |
16 |
17 |
18 |

{room.title}

19 | {room.description} 20 |
21 |
22 | 23 | {t('showMore')} 24 | 25 |
26 |
27 |
28 | ); 29 | }; 30 | -------------------------------------------------------------------------------- /apps/expo/routes/Account/Account.tsx: -------------------------------------------------------------------------------- 1 | import { useAuthService } from '@tens/common/src/services/SessionService'; 2 | import { Button, useToast, VStack } from 'native-base'; 3 | import { ReactElement } from 'react'; 4 | import { useTranslation } from 'react-i18next'; 5 | import { useMutation } from 'react-query'; 6 | 7 | export const Account = (): ReactElement => { 8 | const { t } = useTranslation('common', { keyPrefix: 'Account' }); 9 | 10 | const toast = useToast(); 11 | 12 | const authService = useAuthService(); 13 | 14 | const mutation = useMutation(authService.signOut, { 15 | onError: () => { 16 | toast.show({ 17 | title: t('error'), 18 | description: t('errorText'), 19 | }); 20 | }, 21 | }); 22 | 23 | const handleSignOutPress = () => { 24 | mutation.mutate(); 25 | }; 26 | 27 | return ( 28 | 29 | 32 | 33 | ); 34 | }; 35 | -------------------------------------------------------------------------------- /apps/expo/routes/Room/Room.tsx: -------------------------------------------------------------------------------- 1 | import type { StackScreenProps } from '@react-navigation/stack'; 2 | import { VStack } from 'native-base'; 3 | import { ReactElement, useState } from 'react'; 4 | import { SafeAreaView } from 'react-native'; 5 | import type { RoomsNavigatorParams } from '../Router'; 6 | import { AddQuestion } from './AddQuestion/AddQuestion'; 7 | import { Questions } from './Questions/Questions'; 8 | import { RoomHeading } from './RoomHeading/RoomHeading'; 9 | 10 | export const Room = ({ 11 | route, 12 | }: StackScreenProps): ReactElement => { 13 | const roomId = route.params.roomId; 14 | 15 | const [showAnswered, setShowAnswered] = useState(); 16 | 17 | return ( 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | ); 26 | }; 27 | -------------------------------------------------------------------------------- /apps/next/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import * as Toast from '@radix-ui/react-toast'; 2 | import { SessionServiceProvider } from '@tens/common/src/services/SessionService'; 3 | import { trpc } from '@tens/next/utils/trpc'; 4 | import { appWithTranslation } from 'next-i18next'; 5 | import { AppType } from 'next/dist/shared/lib/utils'; 6 | import { ReactQueryDevtools } from 'react-query/devtools'; 7 | import '../styles/globals.css'; 8 | import { supabase } from '../utils/supabase'; 9 | 10 | const MyApp: AppType = ({ Component, pageProps }) => { 11 | return ( 12 | 13 | 14 | 15 | 16 | 17 | {process.env.NODE_ENV !== 'production' && ( 18 | 19 | )} 20 | 21 | ); 22 | }; 23 | 24 | export default trpc.withTRPC(appWithTranslation(MyApp as any)); 25 | -------------------------------------------------------------------------------- /packages/api/src/context.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from '@prisma/client'; 2 | import { createClient } from '@supabase/supabase-js'; 3 | import * as trpc from '@trpc/server'; 4 | import * as trpcNext from '@trpc/server/adapters/next'; 5 | 6 | const prisma = new PrismaClient({ 7 | log: process.env.NODE_ENV === 'development' ? ['error', 'warn'] : ['error'], 8 | }); 9 | 10 | const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || ''; 11 | const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || ''; 12 | const supabase = createClient(supabaseUrl, supabaseAnonKey); 13 | 14 | export const createContext = async ({ 15 | req, 16 | res, 17 | }: trpcNext.CreateNextContextOptions) => { 18 | const token = req.headers.authorization; 19 | const jwt = token?.split(' ')[1]; 20 | 21 | if (jwt) { 22 | const { user } = await supabase.auth.api.getUser(jwt); 23 | return { req, res, prisma, user, supabase }; 24 | } 25 | 26 | return { req, res, prisma, user: null, supabase }; 27 | }; 28 | 29 | export type Context = trpc.inferAsyncReturnType; 30 | -------------------------------------------------------------------------------- /apps/expo/routes/Rooms/RoomsItem/RoomsItem.tsx: -------------------------------------------------------------------------------- 1 | import { Room } from '@prisma/client'; 2 | import { NavigationProp, useNavigation } from '@react-navigation/core'; 3 | import type { RoomsNavigatorParams } from '@tens/expo/routes/Router'; 4 | import { Box, Heading, Text, VStack } from 'native-base'; 5 | import { memo, ReactElement } from 'react'; 6 | import { TouchableOpacity } from 'react-native'; 7 | 8 | type Props = { 9 | room: Room; 10 | }; 11 | 12 | const RoomsItemInner = ({ room }: Props): ReactElement => { 13 | const navigation = useNavigation>(); 14 | 15 | const handleItemPress = () => { 16 | navigation.navigate('Room', { roomId: room.id }); 17 | }; 18 | 19 | return ( 20 | 21 | 22 | 23 | {room.title} 24 | {room.description} 25 | 26 | 27 | 28 | ); 29 | }; 30 | 31 | export const RoomsItem = memo(RoomsItemInner); 32 | -------------------------------------------------------------------------------- /packages/common/src/services/useQuestionSubscription.ts: -------------------------------------------------------------------------------- 1 | import { SupabaseClient } from '@supabase/supabase-js'; 2 | import type { AppRouter } from '@tens/api/src/routes'; 3 | import type { CreateReactQueryHooks } from '@trpc/react/dist/createReactQueryHooks'; 4 | import { useEffect } from 'react'; 5 | import { useDebounce } from '../hooks/useDebounce'; 6 | 7 | type Props = { 8 | roomId: string; 9 | supabase: SupabaseClient; 10 | trpc: Pick, 'useContext' | 'useMutation'>; 11 | }; 12 | 13 | export const useQuestionsSubscription = ({ roomId, trpc, supabase }: Props) => { 14 | const trpcContext = trpc.useContext(); 15 | 16 | const invalidate = useDebounce(() => { 17 | trpcContext.invalidateQueries(['question.list']); 18 | }, 250); 19 | 20 | useEffect(() => { 21 | const subscription = supabase 22 | .from(`Question:roomId=eq.${roomId}`) 23 | .on('*', () => invalidate({})) 24 | .subscribe(); 25 | 26 | return () => { 27 | subscription.unsubscribe(); 28 | }; 29 | }, [invalidate, roomId, supabase]); 30 | }; 31 | -------------------------------------------------------------------------------- /apps/next/public/vercel.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /apps/expo/routes/Room/AddQuestion/AddQuestion.tsx: -------------------------------------------------------------------------------- 1 | import { AddIcon, Box, Fab, Modal, useDisclose } from 'native-base'; 2 | import { ReactElement } from 'react'; 3 | import { useTranslation } from 'react-i18next'; 4 | import { AddQuestionForm } from './AddQuestionForm/AddQuestionForm'; 5 | 6 | type Props = { 7 | roomId: string; 8 | }; 9 | 10 | export const AddQuestion = ({ roomId }: Props): ReactElement => { 11 | const { t } = useTranslation('common', { keyPrefix: 'Room.AddQuestion' }); 12 | 13 | const { isOpen, onClose, onOpen } = useDisclose(); 14 | 15 | return ( 16 | 17 | } 19 | onPress={onOpen} 20 | renderInPortal={false} 21 | shadow={2} 22 | size="lg" 23 | /> 24 | 25 | 26 | 27 | {t('header')} 28 | 29 | 30 | 31 | 32 | 33 | 34 | ); 35 | }; 36 | -------------------------------------------------------------------------------- /apps/next/components/Pagination/Pagination.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | import { ReactElement } from 'react'; 3 | 4 | type Props = { 5 | current: number; 6 | count: number; 7 | pageSize: number; 8 | onChange: (page: number) => void; 9 | }; 10 | 11 | export const Pagination = ({ 12 | count, 13 | current, 14 | pageSize, 15 | onChange, 16 | }: Props): ReactElement => { 17 | const maxPage = Math.ceil(Math.max(count - 1, 0) / pageSize); 18 | 19 | console.log({ count, maxPage, pageSize }); 20 | 21 | const pages = [ 22 | 0, 23 | Math.max(current - 1, 0), 24 | current, 25 | Math.min(current + 1, maxPage - 1), 26 | maxPage - 1, 27 | ].reduce((prev, curr) => { 28 | if (prev.indexOf(curr) < 0) prev.push(curr); 29 | return prev; 30 | }, [] as number[]); 31 | 32 | return ( 33 |
34 | {pages.map((page) => ( 35 | 42 | ))} 43 |
44 | ); 45 | }; 46 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", // Specifies the ESLint parser 3 | "extends": [ 4 | "plugin:@typescript-eslint/recommended", // Uses the recommended rules from the @typescript-eslint/eslint-plugin 5 | "plugin:react/recommended", 6 | "plugin:react-hooks/recommended", 7 | "plugin:prettier/recommended" 8 | ], 9 | "parserOptions": { 10 | "ecmaVersion": 2018, // Allows for the parsing of modern ECMAScript features 11 | "sourceType": "module" // Allows for the use of imports 12 | }, 13 | "rules": { 14 | // Place to specify ESLint rules. Can be used to overwrite rules specified from the extended configs 15 | "@typescript-eslint/explicit-function-return-type": "off", 16 | "@typescript-eslint/explicit-module-boundary-types": "off", 17 | "react/react-in-jsx-scope": "off", 18 | "react/prop-types": "off", 19 | "@typescript-eslint/no-explicit-any": "off" 20 | }, 21 | "overrides": [ 22 | { 23 | "files": ["examples/**/*"], 24 | "rules": { 25 | "@typescript-eslint/no-unused-vars": "off" 26 | } 27 | } 28 | ], 29 | "settings": { 30 | "react": { 31 | "version": "detect" 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /apps/next/pages/login.tsx: -------------------------------------------------------------------------------- 1 | import { useSessionStatus } from '@tens/common/src/services/SessionService'; 2 | import { GetServerSideProps } from 'next'; 3 | import { useTranslation } from 'next-i18next'; 4 | import Head from 'next/head'; 5 | import { ReactElement } from 'react'; 6 | import { Loader } from '../components/Loader/Loader'; 7 | import { SendMagicLink } from '../modules/SendMagicLink/SendMagicLink'; 8 | import { SignIn } from '../modules/SignIn/SignIn'; 9 | import { usePublicPath } from '../utils/paths'; 10 | import { withTranslations } from '../utils/withTranslations'; 11 | 12 | const LoginPage = (): ReactElement => { 13 | const { t } = useTranslation('common', { keyPrefix: 'Navigation' }); 14 | 15 | usePublicPath(); 16 | 17 | const sessionStatus = useSessionStatus(); 18 | 19 | return ( 20 | <> 21 | 22 | {t('title')} 23 | 24 | 25 | {sessionStatus === 'idle' && } 26 | {sessionStatus === 'anon' && ( 27 | <> 28 | 29 | 30 | 31 | )} 32 | 33 | ); 34 | }; 35 | 36 | export const getServerSideProps: GetServerSideProps = withTranslations(); 37 | 38 | export default LoginPage; 39 | -------------------------------------------------------------------------------- /packages/api/src/routes/vote.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | import { protectedProcedure, t } from '../trpc'; 3 | 4 | export const voteRouter = t.router({ 5 | toggle: protectedProcedure 6 | .input( 7 | z.object({ 8 | questionId: z.string().uuid(), 9 | content: z.string().min(1).max(32), 10 | }), 11 | ) 12 | .mutation(async ({ ctx, input }) => { 13 | const question = await ctx.prisma.question.findFirstOrThrow({ 14 | where: { id: input.questionId }, 15 | }); 16 | 17 | await ctx.prisma.member.findFirstOrThrow({ 18 | where: { roomId: question.roomId, userId: ctx.user.id }, 19 | }); 20 | 21 | const vote = await ctx.prisma.vote.findFirst({ 22 | where: { questionId: input.questionId, userId: ctx.user.id }, 23 | }); 24 | 25 | if (vote?.content === input.content) { 26 | return ctx.prisma.vote.delete({ 27 | where: { id: vote.id }, 28 | }); 29 | } 30 | 31 | return ctx.prisma.vote.upsert({ 32 | where: { 33 | questionId_userId: { 34 | questionId: input.questionId, 35 | userId: ctx.user.id, 36 | }, 37 | }, 38 | create: { ...input, userId: ctx.user.id }, 39 | update: { ...input }, 40 | }); 41 | }), 42 | }); 43 | -------------------------------------------------------------------------------- /apps/expo/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .expo/ 3 | dist/ 4 | npm-debug.* 5 | *.jks 6 | *.p8 7 | *.p12 8 | *.key 9 | *.mobileprovision 10 | *.orig.* 11 | web-build/ 12 | 13 | # macOS 14 | .DS_Store 15 | 16 | node_modules/**/* 17 | .expo/* 18 | .next/* 19 | npm-debug.* 20 | yarn-error.* 21 | *.jks 22 | *.p8 23 | *.p12 24 | *.key 25 | *.mobileprovision 26 | *.orig.* 27 | web-build/ 28 | __generated__/ 29 | # @generated expo-cli sync-2138f1e3e130677ea10ea873f6d498e3890e677b 30 | # The following patterns were generated by expo-cli 31 | 32 | # OSX 33 | # 34 | .DS_Store 35 | 36 | # Xcode 37 | # 38 | build/ 39 | *.pbxuser 40 | !default.pbxuser 41 | *.mode1v3 42 | !default.mode1v3 43 | *.mode2v3 44 | !default.mode2v3 45 | *.perspectivev3 46 | !default.perspectivev3 47 | xcuserdata 48 | *.xccheckout 49 | *.moved-aside 50 | DerivedData 51 | *.hmap 52 | *.ipa 53 | *.xcuserstate 54 | project.xcworkspace 55 | 56 | # Android/IntelliJ 57 | # 58 | build/ 59 | .idea 60 | .gradle 61 | local.properties 62 | *.iml 63 | *.hprof 64 | 65 | # node.js 66 | # 67 | node_modules/ 68 | npm-debug.log 69 | yarn-error.log 70 | 71 | # BUCK 72 | buck-out/ 73 | \.buckd/ 74 | *.keystore 75 | !debug.keystore 76 | 77 | # Bundle artifacts 78 | *.jsbundle 79 | 80 | # CocoaPods 81 | /ios/Pods/ 82 | 83 | # Expo 84 | .expo/ 85 | web-build/ 86 | 87 | # @end expo-cli 88 | 89 | # Native 90 | ios/ 91 | android/ 92 | 93 | .env -------------------------------------------------------------------------------- /apps/next/modules/RoomHeading/RoomHeading.tsx: -------------------------------------------------------------------------------- 1 | import { ErrorMessage } from '@tens/next/components/ErrorMessage/ErrorMessage'; 2 | import { Loader } from '@tens/next/components/Loader/Loader'; 3 | import { trpc } from '@tens/next/utils/trpc'; 4 | import { useTranslation } from 'next-i18next'; 5 | import Link from 'next/link'; 6 | import { ReactElement } from 'react'; 7 | import { paths } from '../../utils/paths'; 8 | 9 | type Props = { 10 | roomId: string; 11 | }; 12 | 13 | export const RoomHeading = ({ roomId }: Props): ReactElement => { 14 | const { t } = useTranslation('common', { keyPrefix: 'Room.RoomHeading' }); 15 | 16 | const query = trpc.proxy.room.get.useQuery( 17 | { id: roomId }, 18 | { refetchOnWindowFocus: false }, 19 | ); 20 | 21 | if (query.status === 'error') { 22 | return ( 23 | query.refetch()} 26 | /> 27 | ); 28 | } 29 | 30 | if (query.status === 'loading' || query.status === 'idle') { 31 | return ; 32 | } 33 | 34 | return ( 35 |
36 |

{query.data.title}

37 |

{query.data.description}

38 | 39 | {t('roomSettings')} 40 | 41 |
42 | ); 43 | }; 44 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # OSX 2 | # 3 | .DS_Store 4 | 5 | # Xcode 6 | # 7 | build/ 8 | *.pbxuser 9 | !default.pbxuser 10 | *.mode1v3 11 | !default.mode1v3 12 | *.mode2v3 13 | !default.mode2v3 14 | *.perspectivev3 15 | !default.perspectivev3 16 | xcuserdata 17 | *.xccheckout 18 | *.moved-aside 19 | DerivedData 20 | *.hmap 21 | *.ipa 22 | *.xcuserstate 23 | project.xcworkspace 24 | 25 | # Android/IntelliJ 26 | # 27 | build/ 28 | .idea 29 | .gradle 30 | local.properties 31 | *.iml 32 | 33 | # node.js 34 | # 35 | node_modules/ 36 | npm-debug.log 37 | yarn-error.log 38 | 39 | # BUCK 40 | buck-out/ 41 | \.buckd/ 42 | *.keystore 43 | 44 | # fastlane 45 | # 46 | # It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the 47 | # screenshots whenever they are needed. 48 | # For more information about the recommended setup visit: 49 | # https://docs.fastlane.tools/best-practices/source-control/ 50 | 51 | */fastlane/report.xml 52 | */fastlane/Preview.html 53 | */fastlane/screenshots 54 | 55 | # Bundle artifacts 56 | *.jsbundle 57 | 58 | # CocoaPods 59 | /ios/Pods/ 60 | 61 | # Expo 62 | .expo/* 63 | web-build/ 64 | 65 | **/*/.expo 66 | 67 | **/*/.next 68 | 69 | **/*/ios 70 | **/*/android 71 | 72 | .turbo 73 | build/** 74 | 75 | *.log 76 | .DS_Store 77 | node_modules 78 | dist 79 | 80 | node_modules 81 | package-lock.json 82 | 83 | .yalc 84 | yalc.lock 85 | 86 | coverage/ 87 | *.db 88 | .env 89 | -------------------------------------------------------------------------------- /apps/expo/App.tsx: -------------------------------------------------------------------------------- 1 | import 'react-native-gesture-handler'; 2 | // 3 | import { SessionServiceProvider } from '@tens/common/src/services/SessionService'; 4 | import { NativeBaseProvider } from 'native-base'; 5 | import { ReactElement, useMemo, useState } from 'react'; 6 | import { I18nextProvider } from 'react-i18next'; 7 | import { SafeAreaProvider } from 'react-native-safe-area-context'; 8 | import { QueryClient, QueryClientProvider } from 'react-query'; 9 | import { Router } from './routes/Router'; 10 | import i18next from './utils/i18next'; 11 | import { supabase } from './utils/supabase'; 12 | import { createTrpcClient, trpc } from './utils/trpc'; 13 | 14 | const App = (): ReactElement => { 15 | const [queryClient] = useState(() => new QueryClient()); 16 | const trpcClient = useMemo(() => createTrpcClient(), []); 17 | 18 | return ( 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | ); 33 | }; 34 | 35 | export default App; 36 | -------------------------------------------------------------------------------- /apps/next/modules/Navbar/Logout/Logout.tsx: -------------------------------------------------------------------------------- 1 | import { useAuthService } from '@tens/common/src/services/SessionService'; 2 | import { Toast, ToastElement } from '@tens/next/components/Toast/Toast'; 3 | import { paths } from '@tens/next/utils/paths'; 4 | import clsx from 'clsx'; 5 | import { useTranslation } from 'next-i18next'; 6 | import { useRouter } from 'next/router'; 7 | import { ReactElement, useRef } from 'react'; 8 | import { useMutation } from 'react-query'; 9 | 10 | export const Logout = (): ReactElement => { 11 | const { t } = useTranslation('common', { keyPrefix: 'Account' }); 12 | 13 | const router = useRouter(); 14 | 15 | const toastRef = useRef(null); 16 | 17 | const authService = useAuthService(); 18 | 19 | const mutation = useMutation(authService.signOut, { 20 | onSuccess: () => { 21 | router.push(paths.login); 22 | }, 23 | onError: () => { 24 | toastRef.current?.publish(); 25 | }, 26 | }); 27 | 28 | const handleSignOutPress = () => { 29 | mutation.mutate(); 30 | }; 31 | 32 | return ( 33 | <> 34 | 41 | 42 | {t('errorText')} 43 | 44 | 45 | ); 46 | }; 47 | -------------------------------------------------------------------------------- /apps/expo/routes/Room/Questions/QuestionsItem/DeleteAction/DeleteAction.tsx: -------------------------------------------------------------------------------- 1 | import type { InferQueryOutput } from '@tens/api/src/types'; 2 | import { useDeleteQuestionMutation } from '@tens/common/src/services/useDeleteQuestionMutation'; 3 | import { trpc } from '@tens/expo/utils/trpc'; 4 | import { Actionsheet, DeleteIcon, useToast } from 'native-base'; 5 | import { ReactElement } from 'react'; 6 | import { useTranslation } from 'react-i18next'; 7 | 8 | type Props = { 9 | question: InferQueryOutput<'question.list'>['questions'][0]; 10 | showAnswered?: boolean; 11 | take: number; 12 | }; 13 | 14 | export const DeleteAction = ({ 15 | question, 16 | showAnswered, 17 | take, 18 | }: Props): ReactElement => { 19 | const { t } = useTranslation('common', { keyPrefix: 'Room.Questions' }); 20 | 21 | const toast = useToast(); 22 | 23 | const mutation = useDeleteQuestionMutation({ 24 | question, 25 | take, 26 | trpc, 27 | showAnswered, 28 | onError: (error) => { 29 | toast.show({ 30 | title: t('error'), 31 | description: error.message, 32 | }); 33 | }, 34 | }); 35 | 36 | const handlePress = () => { 37 | mutation.mutate({ id: question.id }); 38 | }; 39 | 40 | return ( 41 | } 43 | isDisabled={mutation.isLoading} 44 | onPress={handlePress} 45 | > 46 | {t('delete')} 47 | 48 | ); 49 | }; 50 | -------------------------------------------------------------------------------- /apps/next/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import { useSessionStatus } from '@tens/common/src/services/SessionService'; 2 | import { Loader } from '@tens/next/components/Loader/Loader'; 3 | import { AddRoom } from '@tens/next/modules/AddRoom/AddRoom'; 4 | import { Navbar } from '@tens/next/modules/Navbar/Navbar'; 5 | import { Rooms } from '@tens/next/modules/Rooms/Rooms'; 6 | import { useProtectedPath } from '@tens/next/utils/paths'; 7 | import { GetServerSideProps } from 'next'; 8 | import { useTranslation } from 'next-i18next'; 9 | import Head from 'next/head'; 10 | import { ReactElement } from 'react'; 11 | import { withTranslations } from '../utils/withTranslations'; 12 | 13 | const IndexPage = (): ReactElement => { 14 | const { t } = useTranslation('common', { keyPrefix: 'Navigation' }); 15 | 16 | useProtectedPath(); 17 | 18 | const sessionStatus = useSessionStatus(); 19 | 20 | return ( 21 | <> 22 | 23 | {t('title')} 24 | 25 | 26 | {sessionStatus === 'idle' && } 27 | {sessionStatus === 'auth' && ( 28 | <> 29 | 30 |
31 | 32 | 33 |
34 | 35 | )} 36 | 37 | ); 38 | }; 39 | 40 | export const getServerSideProps: GetServerSideProps = withTranslations(); 41 | 42 | export default IndexPage; 43 | -------------------------------------------------------------------------------- /apps/next/modules/Questions/QuestionsItem/QuestionMenu/AnswerAction/AnswerAction.tsx: -------------------------------------------------------------------------------- 1 | import * as DropdownMenu from '@radix-ui/react-dropdown-menu'; 2 | import type { InferQueryOutput } from '@tens/api/src/types'; 3 | import { useAnswerQuestionMutation } from '@tens/common/src/services/useAnswerQuestionMutation'; 4 | import { Toast, ToastElement } from '@tens/next/components/Toast/Toast'; 5 | import { trpc } from '@tens/next/utils/trpc'; 6 | import { useTranslation } from 'next-i18next'; 7 | import { ReactElement, useRef } from 'react'; 8 | 9 | type Props = { 10 | question: InferQueryOutput<'question.list'>['questions'][0]; 11 | }; 12 | 13 | export const AnswerAction = ({ question }: Props): ReactElement => { 14 | const { t } = useTranslation('common', { keyPrefix: 'Room.Questions' }); 15 | 16 | const toastRef = useRef(null); 17 | 18 | const mutation = useAnswerQuestionMutation({ 19 | question, 20 | trpc, 21 | onError: () => { 22 | toastRef.current?.publish(); 23 | }, 24 | }); 25 | 26 | const handlePress = () => { 27 | mutation.mutate({ answered: !question.answered, id: question.id }); 28 | }; 29 | 30 | return ( 31 | <> 32 | 33 | {question.answered ? t('markAsUnanswered') : t('markAsAnswered')} 34 | 35 | 36 | {mutation.error?.message} 37 | 38 | 39 | ); 40 | }; 41 | -------------------------------------------------------------------------------- /apps/next/modules/AddRoom/AddRoom.tsx: -------------------------------------------------------------------------------- 1 | import { Toast, ToastElement } from '@tens/next/components/Toast/Toast'; 2 | import { RoomForm } from '@tens/next/modules/RoomForm/RoomForm'; 3 | import { paths } from '@tens/next/utils/paths'; 4 | import { trpc } from '@tens/next/utils/trpc'; 5 | import { useTranslation } from 'next-i18next'; 6 | import { useRouter } from 'next/router'; 7 | import { ReactElement, useRef } from 'react'; 8 | 9 | export const AddRoom = (): ReactElement => { 10 | const { t } = useTranslation('common', { keyPrefix: 'Rooms.AddRoom' }); 11 | 12 | const router = useRouter(); 13 | 14 | const toastRef = useRef(null); 15 | 16 | const trpcContext = trpc.useContext(); 17 | 18 | const mutation = trpc.proxy.room.add.useMutation({ 19 | onSettled() { 20 | return trpcContext.invalidateQueries(['room.list']); 21 | }, 22 | onSuccess(data) { 23 | router.push(paths.room(data.room.id)); 24 | }, 25 | onError: () => { 26 | toastRef.current?.publish(); 27 | }, 28 | }); 29 | 30 | return ( 31 |
32 |
33 |

{t('header')}

34 | 39 |
40 | 41 | {t('errorDesc')} 42 | 43 |
44 | ); 45 | }; 46 | -------------------------------------------------------------------------------- /apps/expo/routes/Room/Questions/QuestionsItem/AnsweredActions/AnsweredActions.tsx: -------------------------------------------------------------------------------- 1 | import type { InferQueryOutput } from '@tens/api/src/types'; 2 | import { useAnswerQuestionMutation } from '@tens/common/src/services/useAnswerQuestionMutation'; 3 | import { trpc } from '@tens/expo/utils/trpc'; 4 | import { Actionsheet, CheckIcon, MinusIcon, useToast } from 'native-base'; 5 | import { ReactElement } from 'react'; 6 | import { useTranslation } from 'react-i18next'; 7 | 8 | type Props = { 9 | question: InferQueryOutput<'question.list'>['questions'][0]; 10 | }; 11 | 12 | export const AnsweredActions = ({ question }: Props): ReactElement => { 13 | const { t } = useTranslation('common', { keyPrefix: 'Room.Questions' }); 14 | 15 | const toast = useToast(); 16 | 17 | const mutation = useAnswerQuestionMutation({ 18 | question, 19 | trpc, 20 | onError: (error) => { 21 | toast.show({ 22 | title: t('error'), 23 | description: error.message, 24 | }); 25 | }, 26 | }); 27 | 28 | const handlePress = () => { 29 | mutation.mutate({ answered: !question.answered, id: question.id }); 30 | }; 31 | 32 | return ( 33 | : 37 | } 38 | isDisabled={mutation.isLoading} 39 | onPress={handlePress} 40 | > 41 | {question.answered ? t('markAsUnanswered') : t('markAsAnswered')} 42 | 43 | ); 44 | }; 45 | -------------------------------------------------------------------------------- /apps/next/modules/Questions/QuestionsItem/QuestionMenu/QuestionMenu.tsx: -------------------------------------------------------------------------------- 1 | import * as DropdownMenu from '@radix-ui/react-dropdown-menu'; 2 | import type { InferQueryOutput } from '@tens/api/src/types'; 3 | import { ReactElement } from 'react'; 4 | import { FiMenu } from 'react-icons/fi'; 5 | import { AnswerAction } from './AnswerAction/AnswerAction'; 6 | import { DeleteQuestion } from './DeleteQuestion/DeleteQuestion'; 7 | 8 | type Props = { 9 | question: InferQueryOutput<'question.list'>['questions'][0]; 10 | showAnswered?: boolean; 11 | take: number; 12 | }; 13 | 14 | export const QuestionMenu = ({ 15 | question, 16 | take, 17 | showAnswered, 18 | }: Props): ReactElement => { 19 | return ( 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 |
    28 |
  • 29 | 30 |
  • 31 |
  • 32 | 37 |
  • 38 |
39 |
40 |
41 |
42 | ); 43 | }; 44 | -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | datasource db { 2 | provider = "postgresql" 3 | url = env("DATABASE_URL") 4 | } 5 | 6 | generator client { 7 | provider = "prisma-client-js" 8 | } 9 | 10 | model Room { 11 | id String @id @default(uuid()) 12 | createdAt DateTime @default(now()) 13 | description String 14 | questions Question[] 15 | title String 16 | userId String @db.Uuid() 17 | members Member[] 18 | 19 | @@index([userId]) 20 | } 21 | 22 | model Member { 23 | id String @id @default(uuid()) 24 | createdAt DateTime @default(now()) 25 | room Room @relation(fields: [roomId], references: [id], onDelete: Cascade) 26 | roomId String 27 | userId String @db.Uuid() 28 | 29 | @@index([userId]) 30 | } 31 | 32 | model Question { 33 | id String @id @default(uuid()) 34 | answered Boolean @default(false) 35 | createdAt DateTime @default(now()) 36 | content String 37 | room Room @relation(fields: [roomId], references: [id], onDelete: Cascade) 38 | roomId String 39 | userId String @db.Uuid() 40 | votes Vote[] 41 | 42 | @@index([userId]) 43 | } 44 | 45 | model Vote { 46 | id String @id @default(uuid()) 47 | createdAt DateTime @default(now()) 48 | content String 49 | question Question @relation(fields: [questionId], references: [id], onDelete: Cascade) 50 | questionId String 51 | userId String @db.Uuid() 52 | 53 | @@unique([questionId, userId]) 54 | @@index([userId]) 55 | } 56 | -------------------------------------------------------------------------------- /apps/next/pages/room/[id]/settings.tsx: -------------------------------------------------------------------------------- 1 | import { useSessionStatus } from '@tens/common/src/services/SessionService'; 2 | import { Loader } from '@tens/next/components/Loader/Loader'; 3 | import { Navbar } from '@tens/next/modules/Navbar/Navbar'; 4 | import { RoomHeading } from '@tens/next/modules/RoomHeading/RoomHeading'; 5 | import { useProtectedPath } from '@tens/next/utils/paths'; 6 | import { withTranslations } from '@tens/next/utils/withTranslations'; 7 | import { GetServerSideProps } from 'next'; 8 | import { useTranslation } from 'next-i18next'; 9 | import { useRouter } from 'next/dist/client/router'; 10 | import Head from 'next/head'; 11 | import { ReactElement } from 'react'; 12 | 13 | const RoomSettingsPage = (): ReactElement => { 14 | const { t } = useTranslation('common', { keyPrefix: 'Navigation' }); 15 | 16 | useProtectedPath(); 17 | 18 | const sessionStatus = useSessionStatus(); 19 | 20 | const roomId = useRouter().query.id as string; 21 | 22 | return ( 23 | <> 24 | 25 | {t('title')} 26 | 27 | 28 | {sessionStatus === 'idle' && } 29 | {sessionStatus === 'auth' && ( 30 | <> 31 | 32 |
33 | 34 |
35 | 36 | )} 37 | 38 | ); 39 | }; 40 | 41 | export const getServerSideProps: GetServerSideProps = withTranslations(); 42 | 43 | export default RoomSettingsPage; 44 | -------------------------------------------------------------------------------- /packages/common/src/services/useVoteSubscription.ts: -------------------------------------------------------------------------------- 1 | import { SupabaseClient } from '@supabase/supabase-js'; 2 | import type { AppRouter } from '@tens/api/src/routes'; 3 | import type { CreateReactQueryHooks } from '@trpc/react/dist/createReactQueryHooks'; 4 | import { useEffect } from 'react'; 5 | import { useDebounce } from '../hooks/useDebounce'; 6 | 7 | type Props = { 8 | questionId: string; 9 | voteId?: string; 10 | supabase: SupabaseClient; 11 | trpc: Pick, 'useContext' | 'useMutation'>; 12 | }; 13 | 14 | export const useVoteSubscription = ({ 15 | questionId, 16 | voteId, 17 | trpc, 18 | supabase, 19 | }: Props) => { 20 | const trpcContext = trpc.useContext(); 21 | 22 | const invalidate = useDebounce(() => { 23 | trpcContext.invalidateQueries(['question.get', { questionId }]); 24 | }, 250); 25 | 26 | useEffect(() => { 27 | const subscription = supabase 28 | .from(`Vote:questionId=eq.${questionId}`) 29 | .on('INSERT', async () => invalidate({})) 30 | .subscribe(); 31 | 32 | return () => { 33 | subscription.unsubscribe(); 34 | }; 35 | }, [invalidate, questionId, supabase]); 36 | 37 | useEffect(() => { 38 | if (!voteId) return; 39 | 40 | const subscription = supabase 41 | .from(`Vote:id=eq.${voteId}`) 42 | .on('UPDATE', async () => invalidate({})) 43 | .on('DELETE', async () => invalidate({})) 44 | .subscribe(); 45 | 46 | return () => { 47 | subscription.unsubscribe(); 48 | }; 49 | }, [invalidate, voteId, supabase]); 50 | }; 51 | -------------------------------------------------------------------------------- /apps/expo/routes/Room/RoomHeading/RoomActions/DeleteRoomAction/DeleteRoomAction.tsx: -------------------------------------------------------------------------------- 1 | import { Room } from '@prisma/client'; 2 | import { NavigationProp, useNavigation } from '@react-navigation/native'; 3 | import type { RoomsNavigatorParams } from '@tens/expo/routes/Router'; 4 | import { trpc } from '@tens/expo/utils/trpc'; 5 | import { Actionsheet, DeleteIcon, useToast } from 'native-base'; 6 | import { ReactElement } from 'react'; 7 | import { useTranslation } from 'react-i18next'; 8 | 9 | type Props = { 10 | room: Room; 11 | onClose: () => void; 12 | }; 13 | 14 | export const DeleteRoomAction = ({ room, onClose }: Props): ReactElement => { 15 | const { t } = useTranslation('common', { keyPrefix: 'Room.RoomHeading' }); 16 | 17 | const navigation = useNavigation>(); 18 | 19 | const toast = useToast(); 20 | 21 | const trpcContext = trpc.useContext(); 22 | 23 | const mutation = trpc.useMutation(['room.delete'], { 24 | onSuccess: () => { 25 | onClose(); 26 | trpcContext.invalidateQueries(['room.list']); 27 | navigation.navigate('Rooms'); 28 | }, 29 | onError: (error) => { 30 | toast.show({ 31 | title: t('error'), 32 | description: error.message, 33 | }); 34 | }, 35 | }); 36 | 37 | const handlePress = () => { 38 | mutation.mutate({ id: room.id }); 39 | }; 40 | 41 | return ( 42 | } 44 | isDisabled={mutation.isLoading} 45 | onPress={handlePress} 46 | > 47 | {t('deleteRoom')} 48 | 49 | ); 50 | }; 51 | -------------------------------------------------------------------------------- /apps/expo/routes/Room/Questions/QuestionsItem/ReactionButton/ReactionButton.tsx: -------------------------------------------------------------------------------- 1 | import type { InferQueryOutput } from '@tens/api/src/types'; 2 | import { useToggleVoteMutation } from '@tens/common/src/services/useToggleVoteMutation'; 3 | import { trpc } from '@tens/expo/utils/trpc'; 4 | import { Text, useToast } from 'native-base'; 5 | import { ReactElement } from 'react'; 6 | import { useTranslation } from 'react-i18next'; 7 | import { TouchableOpacity } from 'react-native'; 8 | 9 | type Props = { 10 | question: InferQueryOutput<'question.list'>['questions'][0]; 11 | reaction: string; 12 | }; 13 | 14 | export const ReactionButton = ({ question, reaction }: Props): ReactElement => { 15 | const { t } = useTranslation('common', { keyPrefix: 'Room.Questions' }); 16 | 17 | const toast = useToast(); 18 | 19 | const mutation = useToggleVoteMutation({ 20 | question, 21 | trpc, 22 | onError: (error) => { 23 | toast.show({ 24 | title: t('error'), 25 | description: error.message, 26 | }); 27 | }, 28 | }); 29 | 30 | const handleReactionClick = () => { 31 | mutation.mutate({ content: reaction, questionId: question.id }); 32 | }; 33 | 34 | const counts = question.counts.find((votes) => votes.content === reaction); 35 | const isSelected = question.vote?.content === reaction; 36 | 37 | return ( 38 | 39 | 40 | {`${reaction}${counts?._count ? ` ${counts._count}` : ''}`} 41 | 42 | 43 | ); 44 | }; 45 | -------------------------------------------------------------------------------- /apps/next/modules/Navbar/Navbar.tsx: -------------------------------------------------------------------------------- 1 | import { useAuthService } from '@tens/common/src/services/SessionService'; 2 | import { paths } from '@tens/next/utils/paths'; 3 | import { useTranslation } from 'next-i18next'; 4 | import Link from 'next/link'; 5 | import { ReactElement } from 'react'; 6 | import { Logout } from './Logout/Logout'; 7 | 8 | export const Navbar = (): ReactElement => { 9 | const { t } = useTranslation('common', { keyPrefix: 'Navigation' }); 10 | 11 | const authService = useAuthService(); 12 | 13 | const avatar = authService.session.user?.email?.slice(0, 2); 14 | 15 | return ( 16 |
17 |
18 | 19 | {t('title')} 20 | 21 |
22 |
23 |
24 | 31 |
    35 |
  • 36 | 37 |
  • 38 |
39 |
40 |
41 |
42 | ); 43 | }; 44 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tens", 3 | "private": true, 4 | "version": "0.0.1", 5 | "author": "w.malarski@gmail.com", 6 | "license": "MIT", 7 | "scripts": { 8 | "db-up": "docker compose up -d", 9 | "db-nuke": "docker compose down --volumes --remove-orphans", 10 | "db-migrate-dev": "yarn prisma migrate dev", 11 | "dev:next": "yarn --cwd apps/next dev", 12 | "dev:expo": "yarn --cwd apps/expo dev", 13 | "lint": "eslint --ext \".js,.ts,.tsx\" --ignore-path .gitignore . && manypkg check", 14 | "lint-fix": "yarn lint --fix && manypkg fix", 15 | "dev": "run-s dev:* --print-label", 16 | "clean": "find . -name node_modules -o -name .next -o -name .expo -type d -prune | xargs rm -rf", 17 | "prisma-dev": "prisma migrate dev && prisma generate" 18 | }, 19 | "workspaces": { 20 | "packages": [ 21 | "apps/*", 22 | "packages/*" 23 | ] 24 | }, 25 | "prettier": { 26 | "printWidth": 80, 27 | "trailingComma": "all", 28 | "singleQuote": true 29 | }, 30 | "dependencies": { 31 | "@manypkg/cli": "^0.19.1", 32 | "@prisma/client": "^4.2.1", 33 | "@types/react": "^17.0.39", 34 | "@typescript-eslint/eslint-plugin": "^5.32.0", 35 | "@typescript-eslint/parser": "^5.32.0", 36 | "eslint": "^7.32.0", 37 | "eslint-config-prettier": "^8.5.0", 38 | "eslint-plugin-prettier": "^4.2.1", 39 | "eslint-plugin-react": "^7.30.1", 40 | "eslint-plugin-react-hooks": "^4.6.0", 41 | "expo-yarn-workspaces": "^2.0.2", 42 | "lerna": "^5.3.0", 43 | "npm-run-all": "^4.1.5", 44 | "prettier": "^2.7.1", 45 | "prisma": "^4.2.1" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /apps/expo/routes/RoomSettings/RoomSettings.tsx: -------------------------------------------------------------------------------- 1 | import { StackScreenProps } from '@react-navigation/stack'; 2 | import { ErrorMessage } from '@tens/expo/components/ErrorMessage/ErrorMessage'; 3 | import { trpc } from '@tens/expo/utils/trpc'; 4 | import { Heading, Skeleton, Text, VStack } from 'native-base'; 5 | import { ReactElement } from 'react'; 6 | import { SafeAreaView } from 'react-native'; 7 | import type { RoomsNavigatorParams } from '../Router'; 8 | import { EditRoom } from './EditRoom/EditRoom'; 9 | 10 | export const RoomSettings = ({ 11 | route, 12 | }: StackScreenProps): ReactElement => { 13 | const roomId = route.params.roomId; 14 | 15 | const query = trpc.useQuery(['room.get', { id: roomId }]); 16 | 17 | if (query.status === 'loading' || query.status === 'idle') { 18 | return ( 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | ); 28 | } 29 | 30 | if (query.status === 'error') { 31 | return ( 32 | query.refetch()} 35 | /> 36 | ); 37 | } 38 | 39 | return ( 40 | 41 | 42 | {query.data.title} 43 | {query.data.description} 44 | 45 | 46 | 47 | ); 48 | }; 49 | -------------------------------------------------------------------------------- /packages/common/src/services/useAnswerQuestionMutation.ts: -------------------------------------------------------------------------------- 1 | import type { AppRouter } from '@tens/api/src/routes'; 2 | import type { InferQueryOutput } from '@tens/api/src/types'; 3 | import type { TRPCClientErrorLike } from '@trpc/client'; 4 | import type { CreateReactQueryHooks } from '@trpc/react/dist/createReactQueryHooks'; 5 | 6 | type Props = { 7 | question: InferQueryOutput<'question.list'>['questions'][0]; 8 | trpc: Pick, 'useContext' | 'useMutation'>; 9 | onError: (error: TRPCClientErrorLike) => void; 10 | }; 11 | 12 | export const useAnswerQuestionMutation = ({ 13 | question, 14 | trpc, 15 | onError, 16 | }: Props) => { 17 | const trpcContext = trpc.useContext(); 18 | 19 | return trpc.useMutation(['question.answer'], { 20 | onMutate: async ({ id, answered }) => { 21 | await trpcContext.cancelQuery(['question.get', { questionId: id }]); 22 | 23 | const previous = trpcContext.getQueryData([ 24 | 'question.get', 25 | { questionId: id }, 26 | ]); 27 | 28 | if (!previous) return {}; 29 | 30 | trpcContext.setQueryData(['question.get', { questionId: id }], { 31 | ...previous, 32 | answered, 33 | }); 34 | 35 | return { previous }; 36 | }, 37 | onError: (err, { id }, context) => { 38 | onError(err); 39 | if (!context?.previous) return; 40 | trpcContext.setQueryData( 41 | ['question.get', { questionId: id }], 42 | context.previous, 43 | ); 44 | }, 45 | onSettled: () => { 46 | trpcContext.invalidateQueries([ 47 | 'question.get', 48 | { questionId: question.id }, 49 | ]); 50 | }, 51 | }); 52 | }; 53 | -------------------------------------------------------------------------------- /apps/next/README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | ``` 12 | 13 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 14 | 15 | You can start editing the page by modifying `pages/index.tsx`. The page auto-updates as you edit the file. 16 | 17 | [API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.ts`. 18 | 19 | The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages. 20 | 21 | ## Learn More 22 | 23 | To learn more about Next.js, take a look at the following resources: 24 | 25 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 26 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 27 | 28 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 29 | 30 | ## Deploy on Vercel 31 | 32 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 33 | 34 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 35 | -------------------------------------------------------------------------------- /apps/next/modules/Questions/QuestionsItem/QuestionMenu/DeleteQuestion/DeleteQuestion.tsx: -------------------------------------------------------------------------------- 1 | import * as DropdownMenu from '@radix-ui/react-dropdown-menu'; 2 | import type { InferQueryOutput } from '@tens/api/src/types'; 3 | import { useDeleteQuestionMutation } from '@tens/common/src/services/useDeleteQuestionMutation'; 4 | import { Toast, ToastElement } from '@tens/next/components/Toast/Toast'; 5 | import { trpc } from '@tens/next/utils/trpc'; 6 | import { useTranslation } from 'next-i18next'; 7 | import { ReactElement, useRef } from 'react'; 8 | import { FiTrash } from 'react-icons/fi'; 9 | 10 | type Props = { 11 | question: InferQueryOutput<'question.list'>['questions'][0]; 12 | showAnswered?: boolean; 13 | take: number; 14 | }; 15 | 16 | export const DeleteQuestion = ({ 17 | question, 18 | showAnswered, 19 | take, 20 | }: Props): ReactElement => { 21 | const { t } = useTranslation('common', { keyPrefix: 'Room.Questions' }); 22 | 23 | const toastRef = useRef(null); 24 | 25 | const mutation = useDeleteQuestionMutation({ 26 | question, 27 | take, 28 | trpc, 29 | showAnswered, 30 | onError: () => { 31 | toastRef.current?.publish(); 32 | }, 33 | }); 34 | 35 | const handleClick = () => { 36 | mutation.mutate({ id: question.id }); 37 | }; 38 | 39 | return ( 40 | <> 41 | 46 | 47 | {t('delete')} 48 | 49 | 50 | {mutation.error?.message} 51 | 52 | 53 | ); 54 | }; 55 | -------------------------------------------------------------------------------- /apps/next/modules/Questions/QuestionsItem/ReactionButton/ReactionButton.tsx: -------------------------------------------------------------------------------- 1 | import type { InferQueryOutput } from '@tens/api/src/types'; 2 | import { useToggleVoteMutation } from '@tens/common/src/services/useToggleVoteMutation'; 3 | import { Toast, ToastElement } from '@tens/next/components/Toast/Toast'; 4 | import { trpc } from '@tens/next/utils/trpc'; 5 | import clsx from 'clsx'; 6 | import { useTranslation } from 'next-i18next'; 7 | import { ReactElement, useRef } from 'react'; 8 | 9 | type Props = { 10 | reaction: string; 11 | question: InferQueryOutput<'question.list'>['questions'][0]; 12 | }; 13 | 14 | export const ReactionButton = ({ reaction, question }: Props): ReactElement => { 15 | const { t } = useTranslation('common', { keyPrefix: 'Room.Questions' }); 16 | 17 | const toastRef = useRef(null); 18 | 19 | const mutation = useToggleVoteMutation({ 20 | question, 21 | trpc, 22 | onError: () => { 23 | toastRef.current?.publish(); 24 | }, 25 | }); 26 | 27 | const handleReactionClick = () => { 28 | mutation.mutate({ content: reaction, questionId: question.id }); 29 | }; 30 | 31 | const counts = question.counts.find((votes) => votes.content === reaction); 32 | const isSelected = question.vote?.content === reaction; 33 | 34 | return ( 35 | <> 36 | 44 | 45 | {mutation.error?.message} 46 | 47 | 48 | ); 49 | }; 50 | -------------------------------------------------------------------------------- /apps/expo/routes/Room/RoomHeading/RoomHeading.tsx: -------------------------------------------------------------------------------- 1 | import { ErrorMessage } from '@tens/expo/components/ErrorMessage/ErrorMessage'; 2 | import { trpc } from '@tens/expo/utils/trpc'; 3 | import { Flex, Heading, HStack, Skeleton, Text, VStack } from 'native-base'; 4 | import { ReactElement } from 'react'; 5 | import { RoomActions } from './RoomActions/RoomActions'; 6 | 7 | type Props = { 8 | roomId: string; 9 | onShowAnsweredChange: (showAnswered?: boolean) => void; 10 | }; 11 | 12 | export const RoomHeading = ({ 13 | roomId, 14 | onShowAnsweredChange, 15 | }: Props): ReactElement => { 16 | const query = trpc.useQuery(['room.get', { id: roomId }]); 17 | 18 | if (query.status === 'loading' || query.status === 'idle') { 19 | return ( 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | ); 30 | } 31 | 32 | if (query.status === 'error') { 33 | return ( 34 | query.refetch()} 37 | /> 38 | ); 39 | } 40 | 41 | return ( 42 | 43 | 44 | {query.data.title} 45 | {query.data.description} 46 | 47 | 48 | 52 | 53 | 54 | ); 55 | }; 56 | -------------------------------------------------------------------------------- /packages/api/src/types.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/naming-convention */ 2 | import type { inferProcedureInput, inferProcedureOutput } from '@trpc/server'; 3 | import type { AppRouter } from './routes'; 4 | 5 | /** 6 | * Enum containing all api query paths 7 | */ 8 | export type TQuery = keyof AppRouter['_def']['queries']; 9 | 10 | /** 11 | * Enum containing all api mutation paths 12 | */ 13 | export type TMutation = keyof AppRouter['_def']['mutations']; 14 | 15 | /** 16 | * Enum containing all api subscription paths 17 | */ 18 | export type TSubscription = keyof AppRouter['_def']['subscriptions']; 19 | 20 | /** 21 | * This is a helper method to infer the output of a query resolver 22 | * @example type HelloOutput = InferQueryOutput<'hello'> 23 | */ 24 | export type InferQueryOutput = inferProcedureOutput< 25 | AppRouter['_def']['queries'][TRouteKey] 26 | >; 27 | 28 | /** 29 | * This is a helper method to infer the input of a query resolver 30 | * @example type HelloInput = InferQueryInput<'hello'> 31 | */ 32 | export type InferQueryInput = inferProcedureInput< 33 | AppRouter['_def']['queries'][TRouteKey] 34 | >; 35 | 36 | /** 37 | * This is a helper method to infer the output of a mutation resolver 38 | * @example type HelloOutput = InferMutationOutput<'hello'> 39 | */ 40 | export type InferMutationOutput = 41 | inferProcedureOutput; 42 | 43 | /** 44 | * This is a helper method to infer the input of a mutation resolver 45 | * @example type HelloInput = InferMutationInput<'hello'> 46 | */ 47 | export type InferMutationInput = 48 | inferProcedureInput; 49 | -------------------------------------------------------------------------------- /apps/next/pages/room/[id]/index.tsx: -------------------------------------------------------------------------------- 1 | import { useSessionStatus } from '@tens/common/src/services/SessionService'; 2 | import { Loader } from '@tens/next/components/Loader/Loader'; 3 | import { AddQuestion } from '@tens/next/modules/AddQuestion/AddQuestion'; 4 | import { Navbar } from '@tens/next/modules/Navbar/Navbar'; 5 | import { Questions } from '@tens/next/modules/Questions/Questions'; 6 | import { RoomHeading } from '@tens/next/modules/RoomHeading/RoomHeading'; 7 | import { useProtectedPath } from '@tens/next/utils/paths'; 8 | import { withTranslations } from '@tens/next/utils/withTranslations'; 9 | import { GetServerSideProps } from 'next'; 10 | import { useTranslation } from 'next-i18next'; 11 | import { useRouter } from 'next/dist/client/router'; 12 | import Head from 'next/head'; 13 | import { ReactElement } from 'react'; 14 | 15 | const RoomPage = (): ReactElement => { 16 | const { t } = useTranslation('common', { keyPrefix: 'Navigation' }); 17 | 18 | useProtectedPath(); 19 | 20 | const sessionStatus = useSessionStatus(); 21 | 22 | const router = useRouter(); 23 | 24 | const roomId = router.query.id as string; 25 | 26 | return ( 27 | <> 28 | 29 | {t('title')} 30 | 31 | 32 | {sessionStatus === 'idle' && } 33 | {sessionStatus === 'auth' && ( 34 | <> 35 | 36 |
37 | 38 | 39 | 40 |
41 | 42 | )} 43 | 44 | ); 45 | }; 46 | 47 | export const getServerSideProps: GetServerSideProps = withTranslations(); 48 | 49 | export default RoomPage; 50 | -------------------------------------------------------------------------------- /apps/expo/app.config.js: -------------------------------------------------------------------------------- 1 | import 'dotenv/config'; 2 | 3 | const STAGE = process.env.STAGE; 4 | 5 | const envConfig = { 6 | development: { 7 | scheme: 'com.example.development', 8 | icon: './assets/icon.development.png', 9 | backgroundColor: '#FF0000', 10 | }, 11 | staging: { 12 | scheme: 'com.example.staging', 13 | icon: './assets/icon.staging.png', 14 | backgroundColor: '#8000FF', 15 | }, 16 | production: { 17 | scheme: 'com.example', 18 | icon: './assets/icon.png', 19 | backgroundColor: '#1610FF', 20 | }, 21 | }; 22 | 23 | const config = envConfig[STAGE || 'development']; 24 | 25 | export default { 26 | name: 'Example', 27 | description: 'Expo + Next.js Monorepo Example', 28 | slug: 'example', 29 | scheme: 'tensapp', 30 | owner: 'poolpoolpool', 31 | icon: config.icon, 32 | sdkVersion: '45.0.0', 33 | version: '0.0.1', 34 | splash: { 35 | image: './assets/splash.png', 36 | resizeMode: 'contain', 37 | backgroundColor: '#000000', 38 | }, 39 | ios: { 40 | bundleIdentifier: config.scheme, 41 | supportsTablet: true, 42 | }, 43 | android: { 44 | package: config.scheme, 45 | versionCode: 1, 46 | adaptiveIcon: { 47 | foregroundImage: './assets/adaptive-icon.png', 48 | backgroundColor: config.backgroundColor, 49 | }, 50 | jsEngine: 'hermes', 51 | }, 52 | androidNavigationBar: { 53 | barStyle: 'dark-content', 54 | backgroundColor: '#FFFFFF', 55 | }, 56 | assetBundlePatterns: ['**/*'], 57 | orientation: 'portrait', 58 | updates: { 59 | fallbackToCacheTimeout: 0, 60 | }, 61 | hooks: { 62 | postPublish: [], 63 | }, 64 | extra: { 65 | STAGE: process.env.STAGE, 66 | supabaseUrl: process.env.SUPABASE_URL, 67 | supabaseAnonKey: process.env.SUPABASE_ANON_KEY, 68 | }, 69 | plugins: [], 70 | }; 71 | -------------------------------------------------------------------------------- /apps/next/components/Toast/Toast.tsx: -------------------------------------------------------------------------------- 1 | import * as ToastPrimitive from '@radix-ui/react-toast'; 2 | import clsx from 'clsx'; 3 | import { 4 | ForwardedRef, 5 | forwardRef, 6 | ReactElement, 7 | useImperativeHandle, 8 | useState, 9 | } from 'react'; 10 | 11 | export type ToastElement = { 12 | publish: () => void; 13 | }; 14 | 15 | type Props = ToastPrimitive.ToastProps & { 16 | variant: 'error' | 'info' | 'success' | 'warning'; 17 | }; 18 | 19 | const ToastInner = ( 20 | props: Props, 21 | forwardedRef: ForwardedRef, 22 | ): ReactElement => { 23 | const { children, title, variant, ...toastProps } = props; 24 | const [count, setCount] = useState(0); 25 | 26 | useImperativeHandle(forwardedRef, () => ({ 27 | publish: () => setCount((count) => count + 1), 28 | })); 29 | 30 | return ( 31 | <> 32 | {Array.from({ length: count }).map((_, index) => ( 33 | 39 |
47 | 48 | {title} 49 | 50 | 51 | {children} 52 | 53 | Dismiss 54 |
55 |
56 | ))} 57 | 58 | ); 59 | }; 60 | 61 | export const Toast = forwardRef(ToastInner); 62 | -------------------------------------------------------------------------------- /apps/expo/routes/RoomSettings/EditRoom/EditRoom.tsx: -------------------------------------------------------------------------------- 1 | import { Room } from '@prisma/client'; 2 | import { NavigationProp, useNavigation } from '@react-navigation/native'; 3 | import { RoomForm, RoomFormData } from '@tens/expo/modules/RoomForm/RoomForm'; 4 | import type { RoomsNavigatorParams } from '@tens/expo/routes/Router'; 5 | import { trpc } from '@tens/expo/utils/trpc'; 6 | import { Heading, useToast } from 'native-base'; 7 | import { ReactElement } from 'react'; 8 | import { useTranslation } from 'react-i18next'; 9 | import { SafeAreaView } from 'react-native'; 10 | 11 | type Props = { 12 | room: Room; 13 | }; 14 | 15 | export const EditRoom = ({ room }: Props): ReactElement => { 16 | const { t } = useTranslation('common', { 17 | keyPrefix: 'RoomSettings.EditRoom', 18 | }); 19 | 20 | const navigation = useNavigation>(); 21 | 22 | const toast = useToast(); 23 | 24 | const trpcContext = trpc.useContext(); 25 | 26 | const mutation = trpc.useMutation(['room.update'], { 27 | onSuccess: (_data, variables) => { 28 | trpcContext.invalidateQueries(['room.list']); 29 | const updated = { ...room, variables }; 30 | trpcContext.setQueryData(['room.get', { id: room.id }], updated); 31 | toast.show({ description: t('successDesc'), title: t('successTitle') }); 32 | }, 33 | onError: () => { 34 | toast.show({ description: t('errorDesc'), title: t('errorTitle') }); 35 | }, 36 | }); 37 | 38 | const handleSubmit = (input: RoomFormData) => { 39 | mutation.mutate({ ...input, id: room.id }); 40 | }; 41 | 42 | const handleCancel = () => { 43 | navigation.navigate('Room', { roomId: room.id }); 44 | }; 45 | 46 | return ( 47 | 48 | {t('header')} 49 | 56 | 57 | ); 58 | }; 59 | -------------------------------------------------------------------------------- /apps/expo/routes/Rooms/Rooms.tsx: -------------------------------------------------------------------------------- 1 | import { ErrorMessage } from '@tens/expo/components/ErrorMessage/ErrorMessage'; 2 | import { trpc } from '@tens/expo/utils/trpc'; 3 | import { FlatList, Skeleton, VStack } from 'native-base'; 4 | import { ReactElement } from 'react'; 5 | import { SafeAreaView } from 'react-native'; 6 | import { AddRoom } from './AddRoom/AddRoom'; 7 | import { RoomsItem } from './RoomsItem/RoomsItem'; 8 | 9 | export const Rooms = (): ReactElement => { 10 | const trpcContext = trpc.useContext(); 11 | 12 | const query = trpc.useInfiniteQuery(['room.list', { take: 10 }], { 13 | onSuccess: (data) => { 14 | data.pages 15 | .flatMap((page) => page.rooms) 16 | .forEach((room) => { 17 | trpcContext.setQueryData(['room.get', { id: room.id }], room); 18 | }); 19 | }, 20 | getNextPageParam: (lastPage) => { 21 | return lastPage.cursor; 22 | }, 23 | }); 24 | 25 | if (query.status === 'loading' || query.status === 'idle') { 26 | return ( 27 | 28 | {[1, 2, 3, 4].map((entry) => ( 29 | 30 | ))} 31 | 32 | ); 33 | } 34 | 35 | if (query.status === 'error') { 36 | return ( 37 | query.refetch()} 39 | message={query.error.message} 40 | /> 41 | ); 42 | } 43 | 44 | const handleRefresh = () => { 45 | query.refetch(); 46 | }; 47 | 48 | const handleEndIsNear = () => { 49 | query.fetchNextPage(); 50 | }; 51 | 52 | return ( 53 | 54 | 55 | page.rooms)} 57 | onRefresh={handleRefresh} 58 | onEndReached={handleEndIsNear} 59 | keyExtractor={(item) => item.id} 60 | refreshing={query.isFetching} 61 | renderItem={({ item }) => } 62 | /> 63 | 64 | 65 | 66 | ); 67 | }; 68 | -------------------------------------------------------------------------------- /apps/expo/routes/Rooms/AddRoom/AddRoom.tsx: -------------------------------------------------------------------------------- 1 | import { NavigationProp, useNavigation } from '@react-navigation/native'; 2 | import { RoomForm, RoomFormData } from '@tens/expo/modules/RoomForm/RoomForm'; 3 | import type { RoomsNavigatorParams } from '@tens/expo/routes/Router'; 4 | import { trpc } from '@tens/expo/utils/trpc'; 5 | import { AddIcon, Box, Fab, Modal, useDisclose, useToast } from 'native-base'; 6 | import { ReactElement } from 'react'; 7 | import { useTranslation } from 'react-i18next'; 8 | 9 | export const AddRoom = (): ReactElement => { 10 | const { t } = useTranslation('common', { keyPrefix: 'Rooms.AddRoom' }); 11 | 12 | const navigation = useNavigation>(); 13 | 14 | const { isOpen, onClose, onOpen } = useDisclose(false); 15 | 16 | const toast = useToast(); 17 | 18 | const trpcContext = trpc.useContext(); 19 | 20 | const mutation = trpc.useMutation(['room.add'], { 21 | onSuccess: (data) => { 22 | trpcContext.invalidateQueries(['room.list']); 23 | trpcContext.setQueryData(['room.get', { id: data.room.id }], data.room); 24 | navigation.navigate('Room', { roomId: data.room.id }); 25 | }, 26 | onError: () => { 27 | toast.show({ description: t('errorDesc'), title: t('errorTitle') }); 28 | }, 29 | }); 30 | 31 | const handleSubmit = (input: RoomFormData) => { 32 | mutation.mutate(input); 33 | }; 34 | 35 | return ( 36 | 37 | } 39 | onPress={onOpen} 40 | renderInPortal={false} 41 | shadow={2} 42 | size="lg" 43 | /> 44 | 45 | 46 | 47 | {t('header')} 48 | 49 | 55 | 56 | 57 | 58 | 59 | ); 60 | }; 61 | -------------------------------------------------------------------------------- /packages/common/src/services/useDeleteQuestionMutation.ts: -------------------------------------------------------------------------------- 1 | import type { AppRouter } from '@tens/api/src/routes'; 2 | import type { InferQueryOutput } from '@tens/api/src/types'; 3 | import type { TRPCClientErrorLike } from '@trpc/client'; 4 | import type { CreateReactQueryHooks } from '@trpc/react/dist/createReactQueryHooks'; 5 | 6 | type Props = { 7 | onError: (error: TRPCClientErrorLike) => void; 8 | question: InferQueryOutput<'question.list'>['questions'][0]; 9 | showAnswered?: boolean; 10 | take: number; 11 | trpc: Pick, 'useContext' | 'useMutation'>; 12 | }; 13 | 14 | export const useDeleteQuestionMutation = ({ 15 | onError, 16 | question, 17 | showAnswered, 18 | take, 19 | trpc, 20 | }: Props) => { 21 | const trpcContext = trpc.useContext(); 22 | 23 | return trpc.useMutation(['question.delete'], { 24 | onMutate: async ({ id }) => { 25 | const args = { roomId: question.roomId, showAnswered, take }; 26 | 27 | await trpcContext.cancelQuery(['question.list', args]); 28 | 29 | const previous = trpcContext.getInfiniteQueryData([ 30 | 'question.list', 31 | args, 32 | ]); 33 | 34 | if (!previous) return {}; 35 | 36 | const next = previous.pages.map((page) => { 37 | const nextQuestions = page.questions.filter( 38 | (question) => question.id !== id, 39 | ); 40 | return { ...page, questions: nextQuestions }; 41 | }); 42 | 43 | trpcContext.setInfiniteQueryData(['question.list', args], { 44 | ...previous, 45 | pages: next, 46 | }); 47 | 48 | return { previous }; 49 | }, 50 | onError: (err, _variables, context) => { 51 | onError(err); 52 | if (!context?.previous) return; 53 | const args = { roomId: question.roomId, showAnswered, take }; 54 | trpcContext.setInfiniteQueryData( 55 | ['question.list', args], 56 | context.previous, 57 | ); 58 | }, 59 | onSettled: () => { 60 | const args = { roomId: question.roomId, showAnswered, take }; 61 | trpcContext.invalidateQueries(['question.list', args]); 62 | }, 63 | }); 64 | }; 65 | -------------------------------------------------------------------------------- /apps/next/modules/Rooms/Rooms.tsx: -------------------------------------------------------------------------------- 1 | import { ErrorMessage } from '@tens/next/components/ErrorMessage/ErrorMessage'; 2 | import { Loader } from '@tens/next/components/Loader/Loader'; 3 | import { trpc } from '@tens/next/utils/trpc'; 4 | import { useTranslation } from 'next-i18next'; 5 | import { useRouter } from 'next/router'; 6 | import { ReactElement } from 'react'; 7 | import { Pagination } from '../../components/Pagination/Pagination'; 8 | import { RoomsItem } from './RoomsItem/RoomsItem'; 9 | 10 | const take = 10; 11 | 12 | export const Rooms = (): ReactElement => { 13 | const { t } = useTranslation('common', { keyPrefix: 'Rooms.List' }); 14 | 15 | const router = useRouter(); 16 | 17 | const page = parseInt(router.query.page?.toString() || '') || 0; 18 | 19 | const trpcContext = trpc.useContext(); 20 | 21 | const query = trpc.proxy.room.page.useQuery( 22 | { take, skip: take * page }, 23 | { 24 | onSuccess(data) { 25 | data.rooms.forEach((room) => { 26 | trpcContext.setQueryData(['room.get', { id: room.id }], room); 27 | }); 28 | }, 29 | }, 30 | ); 31 | 32 | if (query.status === 'loading' || query.status === 'idle') { 33 | return ( 34 |
35 |

{t('header')}

36 | 37 |
38 | ); 39 | } 40 | 41 | if (query.status === 'error') { 42 | return ( 43 | query.refetch()} 46 | /> 47 | ); 48 | } 49 | 50 | const handlePaginationChange = (newPage: number) => { 51 | router.push({ query: { page: newPage } }); 52 | }; 53 | 54 | return ( 55 |
56 |

{t('header')}

57 |
58 | {query.data.rooms.map((room) => ( 59 | 60 | ))} 61 |
62 | 68 |
69 | ); 70 | }; 71 | -------------------------------------------------------------------------------- /apps/next/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@tens/next", 3 | "version": "0.0.1", 4 | "private": true, 5 | "scripts": { 6 | "predev": "prisma generate", 7 | "dev": "next dev", 8 | "migrate-dev": "prisma migrate dev", 9 | "migrate": "prisma migrate deploy", 10 | "start": "next start", 11 | "build:1-generate": "prisma generate", 12 | "build:2-migrate": "prisma migrate deploy", 13 | "build:3-build": "next build", 14 | "build": "run-s build:* --print-label", 15 | "studio": "prisma studio", 16 | "test-dev": "start-server-and-test dev 3000 test", 17 | "test-start": "start-server-and-test start 3000 test", 18 | "test": "jest" 19 | }, 20 | "dependencies": { 21 | "@hookform/resolvers": "^2.9.7", 22 | "@prisma/client": "^4.2.1", 23 | "@radix-ui/react-dropdown-menu": "^1.0.0", 24 | "@radix-ui/react-toast": "^1.0.0", 25 | "@supabase/supabase-js": "^1.35.6", 26 | "@tens/api": "*", 27 | "@trpc/client": "10.0.0-alpha.41", 28 | "@trpc/next": "10.0.0-alpha.41", 29 | "@trpc/react": "10.0.0-alpha.41", 30 | "@trpc/server": "10.0.0-alpha.41", 31 | "clsx": "^1.2.1", 32 | "daisyui": "^2.24.0", 33 | "lodash.debounce": "^4.0.8", 34 | "next": "^12.2.4", 35 | "next-i18next": "^12.0.0", 36 | "prisma": "^4.2.1", 37 | "react": "^17.0.2", 38 | "react-dom": "^17.0.2", 39 | "react-hook-form": "^7.34.1", 40 | "react-icons": "^4.4.0", 41 | "react-query": "^3.39.2", 42 | "superjson": "^1.9.1", 43 | "zod": "^3.17.10" 44 | }, 45 | "devDependencies": { 46 | "@types/jest": "^28.1.6", 47 | "@types/lodash.debounce": "^4.0.7", 48 | "@types/node": "^18.6.4", 49 | "@types/react": "^17.0.39", 50 | "autoprefixer": "^10.4.8", 51 | "eslint-plugin-tailwindcss": "^3.6.0", 52 | "jest": "^28.1.3", 53 | "npm-run-all": "^4.1.5", 54 | "postcss": "^8.4.16", 55 | "prettier": "^2.7.1", 56 | "prettier-plugin-tailwindcss": "^0.1.13", 57 | "tailwindcss": "^3.1.8", 58 | "ts-jest": "^28.0.7", 59 | "typescript": "4.4.2" 60 | }, 61 | "publishConfig": { 62 | "access": "restricted" 63 | }, 64 | "resolutions": { 65 | "**/react": "17.0.2", 66 | "**/react-dom": "17.0.2", 67 | "@types/react": "17.0.2" 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /apps/next/modules/Questions/QuestionsItem/QuestionsItem.tsx: -------------------------------------------------------------------------------- 1 | import type { InferQueryOutput } from '@tens/api/src/types'; 2 | import { useVoteSubscription } from '@tens/common/src/services/useVoteSubscription'; 3 | import { reactions } from '@tens/common/src/utils/reactions'; 4 | import { supabase } from '@tens/next/utils/supabase'; 5 | import { trpc } from '@tens/next/utils/trpc'; 6 | import { ReactElement } from 'react'; 7 | import { QuestionMenu } from './QuestionMenu/QuestionMenu'; 8 | import { ReactionButton } from './ReactionButton/ReactionButton'; 9 | 10 | type Props = { 11 | question: InferQueryOutput<'question.list'>['questions'][0]; 12 | canAnswer?: boolean; 13 | take: number; 14 | showAnswered?: boolean; 15 | }; 16 | 17 | export const QuestionsItem = ({ 18 | question: initialQuestion, 19 | take, 20 | showAnswered, 21 | }: Props): ReactElement => { 22 | useVoteSubscription({ 23 | questionId: initialQuestion.id, 24 | voteId: initialQuestion.vote?.id, 25 | supabase, 26 | trpc, 27 | }); 28 | 29 | const query = trpc.proxy.question.get.useQuery( 30 | { questionId: initialQuestion.id }, 31 | { 32 | initialData: initialQuestion, 33 | refetchOnMount: false, 34 | refetchOnWindowFocus: false, 35 | }, 36 | ); 37 | 38 | const question = query.data || initialQuestion; 39 | 40 | const votesCount = question.counts.reduce( 41 | (prev, curr) => prev + curr._count, 42 | 0, 43 | ); 44 | 45 | return ( 46 |
47 |
48 |
49 |
50 | {votesCount} 51 | {question.content} 52 |
53 | 58 |
59 | {votesCount ? ( 60 |
61 | {reactions.map((reaction) => ( 62 | 67 | ))} 68 |
69 | ) : null} 70 |
71 |
72 | ); 73 | }; 74 | -------------------------------------------------------------------------------- /apps/next/styles/Home.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | padding: 0 2rem; 3 | } 4 | 5 | .main { 6 | min-height: 100vh; 7 | padding: 4rem 0; 8 | flex: 1; 9 | display: flex; 10 | flex-direction: column; 11 | justify-content: center; 12 | align-items: center; 13 | } 14 | 15 | .footer { 16 | display: flex; 17 | flex: 1; 18 | padding: 2rem 0; 19 | border-top: 1px solid #eaeaea; 20 | justify-content: center; 21 | align-items: center; 22 | } 23 | 24 | .footer a { 25 | display: flex; 26 | justify-content: center; 27 | align-items: center; 28 | flex-grow: 1; 29 | } 30 | 31 | .title a { 32 | color: #0070f3; 33 | text-decoration: none; 34 | } 35 | 36 | .title a:hover, 37 | .title a:focus, 38 | .title a:active { 39 | text-decoration: underline; 40 | } 41 | 42 | .title { 43 | margin: 0; 44 | line-height: 1.15; 45 | font-size: 4rem; 46 | } 47 | 48 | .title, 49 | .description { 50 | text-align: center; 51 | } 52 | 53 | .description { 54 | margin: 4rem 0; 55 | line-height: 1.5; 56 | font-size: 1.5rem; 57 | } 58 | 59 | .code { 60 | background: #fafafa; 61 | border-radius: 5px; 62 | padding: 0.75rem; 63 | font-size: 1.1rem; 64 | font-family: Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono, 65 | Bitstream Vera Sans Mono, Courier New, monospace; 66 | } 67 | 68 | .grid { 69 | display: flex; 70 | align-items: center; 71 | justify-content: center; 72 | flex-wrap: wrap; 73 | max-width: 800px; 74 | } 75 | 76 | .card { 77 | margin: 1rem; 78 | padding: 1.5rem; 79 | text-align: left; 80 | color: inherit; 81 | text-decoration: none; 82 | border: 1px solid #eaeaea; 83 | border-radius: 10px; 84 | transition: color 0.15s ease, border-color 0.15s ease; 85 | max-width: 300px; 86 | } 87 | 88 | .card:hover, 89 | .card:focus, 90 | .card:active { 91 | color: #0070f3; 92 | border-color: #0070f3; 93 | } 94 | 95 | .card h2 { 96 | margin: 0 0 1rem 0; 97 | font-size: 1.5rem; 98 | } 99 | 100 | .card p { 101 | margin: 0; 102 | font-size: 1.25rem; 103 | line-height: 1.5; 104 | } 105 | 106 | .logo { 107 | height: 1em; 108 | margin-left: 0.5rem; 109 | } 110 | 111 | @media (max-width: 600px) { 112 | .grid { 113 | width: 100%; 114 | flex-direction: column; 115 | } 116 | } 117 | 118 | @media (prefers-color-scheme: dark) { 119 | .card, 120 | .footer { 121 | border-color: #222; 122 | } 123 | .code { 124 | background: #111; 125 | } 126 | .logo img { 127 | filter: invert(1); 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /apps/expo/routes/Room/Questions/Questions.tsx: -------------------------------------------------------------------------------- 1 | import { useQuestionsSubscription } from '@tens/common/src/services/useQuestionSubscription'; 2 | import { ErrorMessage } from '@tens/expo/components/ErrorMessage/ErrorMessage'; 3 | import { supabase } from '@tens/expo/utils/supabase'; 4 | import { trpc } from '@tens/expo/utils/trpc'; 5 | import { FlatList, Skeleton, VStack } from 'native-base'; 6 | import { ReactElement } from 'react'; 7 | import { SafeAreaView } from 'react-native'; 8 | import { QuestionsItem } from './QuestionsItem/QuestionsItem'; 9 | 10 | type Props = { 11 | roomId: string; 12 | showAnswered?: boolean; 13 | }; 14 | 15 | const take = 10; 16 | 17 | export const Questions = ({ roomId, showAnswered }: Props): ReactElement => { 18 | const trpcContext = trpc.useContext(); 19 | 20 | const query = trpc.useInfiniteQuery( 21 | ['question.list', { take, roomId, answered: showAnswered }], 22 | { 23 | getNextPageParam: (lastPage) => lastPage.cursor, 24 | onSuccess: async (data) => { 25 | data.pages 26 | .flatMap((page) => page.questions) 27 | .forEach((question) => { 28 | trpcContext.setQueryData( 29 | ['question.get', { questionId: question.id }], 30 | question, 31 | ); 32 | }); 33 | }, 34 | }, 35 | ); 36 | 37 | useQuestionsSubscription({ 38 | roomId, 39 | supabase, 40 | trpc, 41 | }); 42 | 43 | if (query.status === 'loading' || query.status === 'idle') { 44 | return ( 45 | 46 | {[1, 2, 3, 4].map((entry) => ( 47 | 48 | ))} 49 | 50 | ); 51 | } 52 | 53 | const handleRefresh = () => { 54 | query.refetch(); 55 | }; 56 | 57 | if (query.status === 'error') { 58 | return ( 59 | 63 | ); 64 | } 65 | 66 | const handleEndReached = () => { 67 | query.fetchNextPage(); 68 | }; 69 | 70 | return ( 71 | 72 | page.questions)} 74 | keyExtractor={(item) => item.id} 75 | onEndReached={handleEndReached} 76 | onRefresh={handleRefresh} 77 | refreshing={query.isFetching} 78 | renderItem={({ item: question }) => ( 79 | 85 | )} 86 | /> 87 | 88 | ); 89 | }; 90 | -------------------------------------------------------------------------------- /packages/common/src/locales/en/common.json: -------------------------------------------------------------------------------- 1 | { 2 | "Navigation": { 3 | "title": "Tens QA" 4 | }, 5 | "Loader": { 6 | "loading": "Loading" 7 | }, 8 | "Account": { 9 | "signOut": "Sign Out" 10 | }, 11 | "SignIn": { 12 | "signIn": "Sign In", 13 | "email": "Email", 14 | "emailPlaceholder": "email@address.com", 15 | "password": "Password", 16 | "passwordPlaceholder": "Password", 17 | "sendLink": "Sign In via link", 18 | "signUp": "Sign Up", 19 | "or": "or" 20 | }, 21 | "SignUp": { 22 | "signUp": "Sign Up", 23 | "email": "Email", 24 | "emailPlaceholder": "email@address.com", 25 | "password": "Password", 26 | "passwordPlaceholder": "Password", 27 | "sendLink": "Sign In via link", 28 | "signIn": "Sign In", 29 | "or": "or" 30 | }, 31 | "SendLink": { 32 | "sendLink": "Send link", 33 | "signIn": "Sign In", 34 | "signUp": "Sign Up", 35 | "email": "Email", 36 | "emailPlaceholder": "email@address.com", 37 | "or": "or" 38 | }, 39 | "Room": { 40 | "AddQuestion": { 41 | "successTitle": "Success", 42 | "successDesc": "Question added", 43 | "errorTitle": "Error", 44 | "errorDesc": "Something went wrong", 45 | "questionLabel": "Question", 46 | "questionPlaceholder": "Question", 47 | "cancel": "Cancel", 48 | "save": "Save", 49 | "header": "Add question" 50 | }, 51 | "Questions": { 52 | "more": "More", 53 | "markAsAnswered": "Mark as answered", 54 | "markAsUnanswered": "Mark as unanswered", 55 | "delete": "Delete question" 56 | }, 57 | "RoomHeading": { 58 | "copyLink": "Copy link", 59 | "showAll": "Show all", 60 | "showAnswered": "Show answered", 61 | "showUnanswered": "Show unanswered", 62 | "deleteRoom": "Delete room", 63 | "roomSettings": "Room settings" 64 | } 65 | }, 66 | "RoomForm": { 67 | "nameLabel": "Name", 68 | "namePlaceholder": "Name", 69 | "descriptionLabel": "Description", 70 | "descriptionPlaceholder": "Description", 71 | "cancel": "Cancel" 72 | }, 73 | "Rooms": { 74 | "AddRoom": { 75 | "errorTitle": "Error", 76 | "errorDesc": "Something went wrong", 77 | "save": "Save", 78 | "header": "Add Room" 79 | }, 80 | "List": { 81 | "header": "Rooms" 82 | } 83 | }, 84 | "RoomSettings": { 85 | "EditRoom": { 86 | "successTitle": "Success", 87 | "successDesc": "Room updated", 88 | "errorTitle": "Error", 89 | "errorDesc": "Something went wrong", 90 | "save": "Save", 91 | "header": "Update Room" 92 | } 93 | }, 94 | "ErrorMessage": { 95 | "reload": "Reload" 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /apps/next/modules/Questions/Questions.tsx: -------------------------------------------------------------------------------- 1 | import { useQuestionsSubscription } from '@tens/common/src/services/useQuestionSubscription'; 2 | import { ErrorMessage } from '@tens/next/components/ErrorMessage/ErrorMessage'; 3 | import { Loader } from '@tens/next/components/Loader/Loader'; 4 | import { supabase } from '@tens/next/utils/supabase'; 5 | import { trpc } from '@tens/next/utils/trpc'; 6 | import { useTranslation } from 'next-i18next'; 7 | import { ReactElement } from 'react'; 8 | import { QuestionsItem } from './QuestionsItem/QuestionsItem'; 9 | 10 | type Props = { 11 | roomId: string; 12 | showAnswered?: boolean; 13 | }; 14 | 15 | const take = 10; 16 | 17 | export const Questions = ({ roomId, showAnswered }: Props): ReactElement => { 18 | const { t } = useTranslation('common', { keyPrefix: 'Room.Questions' }); 19 | 20 | const trpcContext = trpc.useContext(); 21 | 22 | const query = trpc.proxy.question.list.useInfiniteQuery( 23 | { take, roomId, answered: showAnswered }, 24 | { 25 | refetchOnWindowFocus: false, 26 | getNextPageParam: (lastPage) => lastPage.cursor, 27 | onSuccess: async (data) => { 28 | data.pages 29 | .flatMap((page) => page.questions) 30 | .forEach((question) => { 31 | trpcContext.setQueryData( 32 | ['question.get', { questionId: question.id }], 33 | question, 34 | ); 35 | }); 36 | }, 37 | }, 38 | ); 39 | 40 | useQuestionsSubscription({ 41 | roomId, 42 | supabase, 43 | trpc, 44 | }); 45 | 46 | if (query.status === 'loading' || query.status === 'idle') { 47 | return ; 48 | } 49 | 50 | if (query.status === 'error') { 51 | return ( 52 | query.refetch()} 55 | /> 56 | ); 57 | } 58 | 59 | const canAnswer = query.data.pages[0].canAnswer; 60 | 61 | return ( 62 |
63 |

{t('header')}

64 |
65 | {query.data.pages 66 | .flatMap((page) => page.questions) 67 | .map((question) => ( 68 | 75 | ))} 76 | {query.hasNextPage && ( 77 | 80 | )} 81 |
82 |
83 | ); 84 | }; 85 | -------------------------------------------------------------------------------- /apps/next/modules/SendMagicLink/SendMagicLink.tsx: -------------------------------------------------------------------------------- 1 | import { zodResolver } from '@hookform/resolvers/zod'; 2 | import { useAnonService } from '@tens/common/src/services/SessionService'; 3 | import { Toast, ToastElement } from '@tens/next/components/Toast/Toast'; 4 | import clsx from 'clsx'; 5 | import { useTranslation } from 'next-i18next'; 6 | import { ReactElement, useRef } from 'react'; 7 | import { useForm } from 'react-hook-form'; 8 | import { useMutation } from 'react-query'; 9 | import { z } from 'zod'; 10 | 11 | const schema = z.object({ 12 | email: z.string().email(), 13 | }); 14 | 15 | type SendMagicLinkFormData = z.infer; 16 | 17 | export const SendMagicLink = (): ReactElement => { 18 | const { t } = useTranslation('common', { keyPrefix: 'SendLink' }); 19 | 20 | const toastRef = useRef(null); 21 | 22 | const anonService = useAnonService(); 23 | 24 | const mutation = useMutation(anonService.signIn, { 25 | onError: () => { 26 | toastRef.current?.publish(); 27 | }, 28 | }); 29 | 30 | const { register, handleSubmit, formState } = useForm({ 31 | resolver: zodResolver(schema as any), 32 | defaultValues: { email: '' }, 33 | }); 34 | 35 | const onSubmit = (input: SendMagicLinkFormData) => { 36 | mutation.mutate(input); 37 | }; 38 | 39 | return ( 40 |
41 |

{t('sendLink')}

42 | 43 |
44 |
45 | 48 | 58 | {formState.errors.email && ( 59 | 64 | )} 65 |
66 | 73 |
74 | 75 | {t('errorText')} 76 | 77 |
78 | ); 79 | }; 80 | -------------------------------------------------------------------------------- /apps/expo/routes/Router.tsx: -------------------------------------------------------------------------------- 1 | import { createDrawerNavigator } from '@react-navigation/drawer'; 2 | import { NavigationContainer } from '@react-navigation/native'; 3 | import { createNativeStackNavigator } from '@react-navigation/native-stack'; 4 | import { createStackNavigator } from '@react-navigation/stack'; 5 | import { useSessionStatus } from '@tens/common/src/services/SessionService'; 6 | import { ReactElement } from 'react'; 7 | import { Account } from './Account/Account'; 8 | import { Room } from './Room/Room'; 9 | import { Rooms } from './Rooms/Rooms'; 10 | import { RoomSettings } from './RoomSettings/RoomSettings'; 11 | import { SendLink } from './SendLink/SendLink'; 12 | import { SignIn } from './SignIn/SignIn'; 13 | import { SignUp } from './SignUp/SignUp'; 14 | 15 | export type RoomsNavigatorParams = { 16 | Rooms: undefined; 17 | Room: { roomId: string }; 18 | RoomSettings: { roomId: string }; 19 | }; 20 | const RoomsStack = createStackNavigator(); 21 | 22 | const RoomsRouter = (): ReactElement => { 23 | return ( 24 | 25 | 26 | 27 | 28 | 29 | ); 30 | }; 31 | 32 | export type DrawerNavigatorParams = { 33 | RoomsRouter: undefined; 34 | Account: undefined; 35 | }; 36 | 37 | const DrawerStack = createDrawerNavigator(); 38 | 39 | const DrawerRouter = (): ReactElement => { 40 | return ( 41 | 42 | 43 | 44 | 45 | ); 46 | }; 47 | 48 | export type StackParams = { 49 | SendLink: undefined; 50 | SignIn: undefined; 51 | SignUp: undefined; 52 | DrawerRouter: undefined; 53 | }; 54 | 55 | const Stack = createNativeStackNavigator(); 56 | 57 | export const Router = (): ReactElement => { 58 | const status = useSessionStatus(); 59 | 60 | return ( 61 | 62 | 63 | {status === 'anon' && ( 64 | 65 | 66 | 67 | 68 | 69 | )} 70 | {status === 'auth' && ( 71 | 72 | )} 73 | 74 | 75 | ); 76 | }; 77 | -------------------------------------------------------------------------------- /apps/next/utils/trpc.ts: -------------------------------------------------------------------------------- 1 | import type { AppRouter } from '@tens/api/src/routes'; 2 | import { httpBatchLink } from '@trpc/client/links/httpBatchLink'; 3 | import { loggerLink } from '@trpc/client/links/loggerLink'; 4 | import { setupTRPC } from '@trpc/next'; 5 | import { NextPageContext } from 'next'; 6 | import superjson from 'superjson'; 7 | import { supabase } from './supabase'; 8 | 9 | const getBaseUrl = () => { 10 | if (process.browser) { 11 | return ''; 12 | } 13 | // // reference for vercel.com 14 | if (process.env.VERCEL_URL) { 15 | return `https://${process.env.VERCEL_URL}`; 16 | } 17 | 18 | // // reference for render.com 19 | // if (process.env.RENDER_INTERNAL_HOSTNAME) { 20 | // return `http://${process.env.RENDER_INTERNAL_HOSTNAME}:${process.env.PORT}`; 21 | // } 22 | 23 | // assume localhost 24 | return `http://localhost:${process.env.PORT ?? 3000}`; 25 | }; 26 | 27 | /** 28 | * Extend `NextPageContext` with meta data that can be picked up by `responseMeta()` when server-side rendering 29 | */ 30 | // eslint-disable-next-line @typescript-eslint/consistent-type-definitions 31 | export interface SSRContext extends NextPageContext { 32 | /** 33 | * Set HTTP Status code 34 | * @example 35 | * const utils = trpc.useContext(); 36 | * if (utils.ssrContext) { 37 | * utils.ssrContext.status = 404; 38 | * } 39 | */ 40 | status?: number; 41 | } 42 | export const trpc = setupTRPC({ 43 | config() { 44 | const url = `${getBaseUrl()}/api/trpc`; 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/links 52 | */ 53 | links: [ 54 | // adds pretty logs to your console in development and logs errors in production 55 | loggerLink({ 56 | enabled: (opts) => 57 | process.env.NODE_ENV === 'development' || 58 | (opts.direction === 'down' && opts.result instanceof Error), 59 | }), 60 | httpBatchLink({ 61 | url, 62 | }), 63 | ], 64 | /** 65 | * @link https://trpc.io/docs/data-transformers 66 | */ 67 | transformer: superjson, 68 | /** 69 | * @link https://react-query.tanstack.com/reference/QueryClient 70 | */ 71 | // queryClientConfig: { defaultOptions: { queries: { staleTime: 6000 } } }, 72 | headers() { 73 | const session = supabase.auth.session(); 74 | if (!session) return {}; 75 | return Promise.resolve({ 76 | Authorization: `Bearer ${session.access_token}`, 77 | }); 78 | }, 79 | }; 80 | }, 81 | /** 82 | * @link https://trpc.io/docs/ssr 83 | */ 84 | ssr: false, 85 | }); 86 | -------------------------------------------------------------------------------- /apps/next/modules/AddQuestion/AddQuestion.tsx: -------------------------------------------------------------------------------- 1 | import { zodResolver } from '@hookform/resolvers/zod'; 2 | import { Toast, ToastElement } from '@tens/next/components/Toast/Toast'; 3 | import { trpc } from '@tens/next/utils/trpc'; 4 | import clsx from 'clsx'; 5 | import { useTranslation } from 'next-i18next'; 6 | import { ReactElement, useRef } from 'react'; 7 | import { useForm } from 'react-hook-form'; 8 | import { z } from 'zod'; 9 | 10 | const schema = z.object({ 11 | content: z.string().min(5), 12 | }); 13 | 14 | type AddQuestionFormData = z.infer; 15 | 16 | type Props = { 17 | roomId: string; 18 | }; 19 | 20 | export const AddQuestion = ({ roomId }: Props): ReactElement => { 21 | const { t } = useTranslation('common', { keyPrefix: 'Room.AddQuestion' }); 22 | 23 | const toastRef = useRef(null); 24 | 25 | const { register, handleSubmit, reset, formState } = 26 | useForm({ 27 | resolver: zodResolver(schema as any), 28 | defaultValues: { content: '' }, 29 | }); 30 | 31 | const mutation = trpc.proxy.question.add.useMutation({ 32 | onSuccess: () => { 33 | reset(); 34 | }, 35 | onError: () => { 36 | toastRef.current?.publish(); 37 | }, 38 | }); 39 | 40 | const onSubmit = (input: AddQuestionFormData) => { 41 | mutation.mutate({ ...input, roomId }); 42 | }; 43 | 44 | return ( 45 |
46 |
50 |

{t('header')}

51 |
52 | 55 | 65 | {formState.errors.content && ( 66 | 71 | )} 72 |
73 | 74 | 81 |
82 | 83 | {t('errorDesc')} 84 | 85 |
86 | ); 87 | }; 88 | -------------------------------------------------------------------------------- /packages/common/src/services/useToggleVoteMutation.ts: -------------------------------------------------------------------------------- 1 | import type { AppRouter } from '@tens/api/src/routes'; 2 | import type { InferQueryOutput } from '@tens/api/src/types'; 3 | import type { TRPCClientErrorLike } from '@trpc/client'; 4 | import type { CreateReactQueryHooks } from '@trpc/react/dist/createReactQueryHooks'; 5 | 6 | type UseToggleVoteMutation = { 7 | onError: (error: TRPCClientErrorLike) => void; 8 | question: InferQueryOutput<'question.list'>['questions'][0]; 9 | trpc: Pick, 'useContext' | 'useMutation'>; 10 | }; 11 | 12 | export const useToggleVoteMutation = ({ 13 | question, 14 | trpc, 15 | onError, 16 | }: UseToggleVoteMutation) => { 17 | const trpcContext = trpc.useContext(); 18 | 19 | return trpc.useMutation(['vote.toggle'], { 20 | onMutate: async ({ content, questionId }) => { 21 | await trpcContext.cancelQuery(['question.list']); 22 | 23 | const previous = trpcContext.getQueryData([ 24 | 'question.get', 25 | { questionId }, 26 | ]); 27 | 28 | if (!previous) return {}; 29 | 30 | const vote = !question.vote 31 | ? { 32 | content, 33 | createdAt: new Date(), 34 | id: `${Math.random() * 1e16}`, 35 | questionId, 36 | userId: question.userId, 37 | } 38 | : question.vote.content !== content 39 | ? { ...question.vote, content } 40 | : undefined; 41 | 42 | const counts = [...question.counts]; 43 | if (!question.vote || question.vote.content !== content) { 44 | const index = counts.findIndex((e) => e.content === content); 45 | if (index >= 0) { 46 | const count = counts[index]; 47 | counts[index] = { ...count, _count: count._count + 1 }; 48 | } else { 49 | counts.push({ _count: 1, content, questionId }); 50 | } 51 | } 52 | 53 | if (question.vote) { 54 | const currentContent = question.vote?.content; 55 | const index = counts.findIndex((e) => e.content === currentContent); 56 | if (index >= 0) { 57 | const count = counts[index]; 58 | counts[index] = { ...count, _count: count._count - 1 }; 59 | } 60 | } 61 | 62 | trpcContext.setQueryData(['question.get', { questionId }], { 63 | ...question, 64 | vote, 65 | counts, 66 | }); 67 | 68 | return { previous }; 69 | }, 70 | onError: (err, { questionId }, context) => { 71 | onError(err); 72 | if (!context?.previous) return; 73 | trpcContext.setQueryData( 74 | ['question.get', { questionId }], 75 | context.previous, 76 | ); 77 | }, 78 | onSettled: () => { 79 | trpcContext.invalidateQueries([ 80 | 'question.get', 81 | { questionId: question.id }, 82 | ]); 83 | }, 84 | }); 85 | }; 86 | -------------------------------------------------------------------------------- /apps/expo/routes/Room/AddQuestion/AddQuestionForm/AddQuestionForm.tsx: -------------------------------------------------------------------------------- 1 | import { zodResolver } from '@hookform/resolvers/zod'; 2 | import { trpc } from '@tens/expo/utils/trpc'; 3 | import { 4 | Button, 5 | Flex, 6 | FormControl, 7 | TextArea, 8 | useToast, 9 | VStack, 10 | WarningOutlineIcon, 11 | } from 'native-base'; 12 | import { ReactElement } from 'react'; 13 | import { Controller, useForm } from 'react-hook-form'; 14 | import { useTranslation } from 'react-i18next'; 15 | import { z } from 'zod'; 16 | 17 | const schema = z.object({ 18 | content: z.string().min(1), 19 | }); 20 | 21 | type AddQuestionFormData = z.infer; 22 | 23 | type Props = { 24 | roomId: string; 25 | onClose: () => void; 26 | }; 27 | 28 | export const AddQuestionForm = ({ roomId, onClose }: Props): ReactElement => { 29 | const { t } = useTranslation('common', { keyPrefix: 'Room.AddQuestion' }); 30 | 31 | const toast = useToast(); 32 | 33 | const mutation = trpc.useMutation(['question.add'], { 34 | onSuccess: () => { 35 | toast.show({ description: t('successDesc'), title: t('successTitle') }); 36 | onClose(); 37 | }, 38 | onError: () => { 39 | toast.show({ description: t('errorDesc'), title: t('errorTitle') }); 40 | }, 41 | }); 42 | 43 | const { control, handleSubmit } = useForm({ 44 | resolver: zodResolver(schema as any), 45 | defaultValues: { content: '' }, 46 | }); 47 | 48 | const onSubmit = (input: AddQuestionFormData) => { 49 | mutation.mutate({ ...input, roomId }); 50 | }; 51 | 52 | return ( 53 | 54 | ( 59 | 60 | 61 | {t('questionLabel')} 62 |