├── .eslintrc.cjs ├── .gitignore ├── LICENSE ├── README.md ├── components.json ├── convex ├── README.md ├── _generated │ ├── api.d.ts │ ├── api.js │ ├── dataModel.d.ts │ ├── server.d.ts │ └── server.js ├── auth.config.js ├── chat.ts ├── myFunctions.ts ├── schema.ts ├── triviaGames.ts ├── triviaQuestions.ts ├── tsconfig.json ├── types.ts └── users.ts ├── generate-questions-env.mjs ├── index.html ├── package-lock.json ├── package.json ├── postcss.config.js ├── public ├── _redirects └── favicon.svg ├── questions.config.template.ts ├── src ├── App.tsx ├── ErrorBoundary.tsx ├── components │ ├── AuthRoute.tsx │ ├── Chat.tsx │ ├── Countdown.tsx │ ├── Header.tsx │ ├── layout │ │ └── MainLayout.tsx │ ├── typography │ │ ├── code.tsx │ │ └── link.tsx │ └── ui │ │ ├── accordion.tsx │ │ ├── alert-dialog.tsx │ │ ├── alert.tsx │ │ ├── aspect-ratio.tsx │ │ ├── avatar.tsx │ │ ├── badge.tsx │ │ ├── button.tsx │ │ ├── calendar.tsx │ │ ├── card.tsx │ │ ├── checkbox.tsx │ │ ├── collapsible.tsx │ │ ├── command.tsx │ │ ├── context-menu.tsx │ │ ├── dialog.tsx │ │ ├── dropdown-menu.tsx │ │ ├── form.tsx │ │ ├── hover-card.tsx │ │ ├── input.tsx │ │ ├── label.tsx │ │ ├── menubar.tsx │ │ ├── navigation-menu.tsx │ │ ├── popover.tsx │ │ ├── progress.tsx │ │ ├── radio-group.tsx │ │ ├── scroll-area.tsx │ │ ├── select.tsx │ │ ├── separator.tsx │ │ ├── sheet.tsx │ │ ├── skeleton.tsx │ │ ├── slider.tsx │ │ ├── switch.tsx │ │ ├── table.tsx │ │ ├── tabs.tsx │ │ ├── textarea.tsx │ │ ├── toast.tsx │ │ ├── toaster.tsx │ │ ├── toggle.tsx │ │ ├── tooltip.tsx │ │ └── use-toast.ts ├── constants.ts ├── contexts │ └── UserContext.tsx ├── hooks │ ├── useStoreUser.tsx │ └── useuserId.tsx ├── index.css ├── lib │ └── utils.tsx ├── main.tsx ├── pages │ ├── ErrorPage.tsx │ ├── Profile.tsx │ └── trivia │ │ ├── TriviaGame.tsx │ │ ├── TriviaLobby.tsx │ │ └── TriviaResult.tsx └── types │ └── index.ts ├── tailwind.config.js ├── todo ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { browser: true, es2020: true, node: true }, 4 | extends: [ 5 | "eslint:recommended", 6 | "plugin:@typescript-eslint/recommended-type-checked", 7 | "plugin:react-hooks/recommended", 8 | ], 9 | ignorePatterns: [ 10 | "dist", 11 | "convex/_generated", 12 | ".eslintrc.cjs", 13 | "tailwind.config.js", 14 | // There are currently ESLint errors in shadcn/ui 15 | "src/components/ui", 16 | ], 17 | parser: "@typescript-eslint/parser", 18 | parserOptions: { 19 | project: true, 20 | tsconfigRootDir: __dirname, 21 | }, 22 | plugins: ["react-refresh"], 23 | rules: { 24 | "react-refresh/only-export-components": [ 25 | "warn", 26 | { allowConstantExport: true }, 27 | ], 28 | 29 | // All of these overrides ease getting into 30 | // TypeScript, and can be removed for stricter 31 | // linting down the line. 32 | 33 | // Only warn on unused variables, and ignore variables starting with `_` 34 | "@typescript-eslint/no-unused-vars": [ 35 | "warn", 36 | { varsIgnorePattern: "^_", argsIgnorePattern: "^_" }, 37 | ], 38 | 39 | // Allow escaping the compiler 40 | "@typescript-eslint/ban-ts-comment": "error", 41 | 42 | // Allow explicit `any`s 43 | "@typescript-eslint/no-explicit-any": "off", 44 | 45 | // START: Allow implicit `any`s 46 | "@typescript-eslint/no-unsafe-argument": "off", 47 | "@typescript-eslint/no-unsafe-assignment": "off", 48 | "@typescript-eslint/no-unsafe-call": "off", 49 | "@typescript-eslint/no-unsafe-member-access": "off", 50 | "@typescript-eslint/no-unsafe-return": "off", 51 | // END: Allow implicit `any`s 52 | 53 | // Allow async functions without await 54 | // for consistency (esp. Convex `handler`s) 55 | "@typescript-eslint/require-await": "off", 56 | }, 57 | }; 58 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | !**/glob-import/dir/node_modules 2 | .DS_Store 3 | .idea 4 | *.cpuprofile 5 | *.local 6 | *.log 7 | /.vscode/ 8 | /docs/.vitepress/cache 9 | dist 10 | dist-ssr 11 | explorations 12 | node_modules 13 | playground-temp 14 | temp 15 | .eslintcache 16 | .env 17 | .env.local 18 | questions.config.ts -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Forrest Knight 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Dev Trivia 2 | 3 | This project is a live, weekly developer trivia application built with Vite, React, Tailwind CSS, TypeScript, and Convex. 4 | 5 | ## Prerequisites 6 | 7 | Before you begin, ensure you have the following installed: 8 | - [Node.js](https://nodejs.org/) (version 14 or later recommended) 9 | - [npm](https://www.npmjs.com/) (usually comes with Node.js) 10 | - [Git](https://git-scm.com/) 11 | 12 | ## Getting Started 13 | 14 | Follow these steps to set up and run the project locally: 15 | 16 | 1. Clone the repository: 17 | ``` 18 | git clone https://github.com/forrestknight/dev-trivia.git 19 | cd dev-trivia 20 | ``` 21 | 22 | 2. Install dependencies: 23 | ``` 24 | npm install 25 | ``` 26 | 27 | 3. Set up Convex: 28 | - If you haven't already, create a Convex account at [https://dashboard.convex.dev](https://dashboard.convex.dev) 29 | - Run the following command and follow the prompts to create a Convex project: 30 | ``` 31 | npx convex dev 32 | ``` 33 | 34 | 4. Set up Clerk (for authentication): 35 | - Follow the [Convex + Clerk](https://docs.convex.dev/auth/clerk) documentation. 36 | - Copy the Clerk publishable key and add it to your `.env.local` file: 37 | ``` 38 | VITE_CLERK_PUBLISHABLE_KEY=your_publishable_key_here 39 | ``` 40 | 41 | 5. Start the development server: 42 | ``` 43 | npm run dev 44 | ``` 45 | 46 | This command will start both the frontend and backend servers concurrently. 47 | 48 | ## Project Structure 49 | 50 | - `/src`: Contains the React frontend code 51 | - `/convex`: Contains the Convex backend code 52 | - `/public`: Contains public assets 53 | 54 | ## Contributing 55 | 56 | Contributions are welcome! Please feel free to submit a Pull Request. 57 | 58 | ## License 59 | 60 | This project is open source and available under the [MIT License](LICENSE). 61 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.js", 8 | "css": "src/index.css", 9 | "baseColor": "zinc", 10 | "cssVariables": true 11 | }, 12 | "aliases": { 13 | "components": "@/components", 14 | "utils": "@/lib/utils" 15 | } 16 | } -------------------------------------------------------------------------------- /convex/README.md: -------------------------------------------------------------------------------- 1 | # Welcome to your Convex functions directory! 2 | 3 | Write your Convex functions here. 4 | See https://docs.convex.dev/functions for more. 5 | 6 | A query function that takes two arguments looks like: 7 | 8 | ```ts 9 | // functions.js 10 | import { query } from "./_generated/server"; 11 | import { v } from "convex/values"; 12 | 13 | export const myQueryFunction = query({ 14 | // Validators for arguments. 15 | args: { 16 | first: v.number(), 17 | second: v.string(), 18 | }, 19 | 20 | // Function implementation. 21 | handler: async (ctx, args) => { 22 | // Read the database as many times as you need here. 23 | // See https://docs.convex.dev/database/reading-data. 24 | const documents = await ctx.db.query("tablename").collect(); 25 | 26 | // Arguments passed from the client are properties of the args object. 27 | console.log(args.first, args.second); 28 | 29 | // Write arbitrary JavaScript here: filter, aggregate, build derived data, 30 | // remove non-public properties, or create new objects. 31 | return documents; 32 | }, 33 | }); 34 | ``` 35 | 36 | Using this query function in a React component looks like: 37 | 38 | ```ts 39 | const data = useQuery(api.functions.myQueryFunction, { 40 | first: 10, 41 | second: "hello", 42 | }); 43 | ``` 44 | 45 | A mutation function looks like: 46 | 47 | ```ts 48 | // functions.js 49 | import { mutation } from "./_generated/server"; 50 | import { v } from "convex/values"; 51 | 52 | export const myMutationFunction = mutation({ 53 | // Validators for arguments. 54 | args: { 55 | first: v.string(), 56 | second: v.string(), 57 | }, 58 | 59 | // Function implementation. 60 | handler: async (ctx, args) => { 61 | // Insert or modify documents in the database here. 62 | // Mutations can also read from the database like queries. 63 | // See https://docs.convex.dev/database/writing-data. 64 | const message = { body: args.first, author: args.second }; 65 | const id = await ctx.db.insert("messages", message); 66 | 67 | // Optionally, return a value from your mutation. 68 | return await ctx.db.get(id); 69 | }, 70 | }); 71 | ``` 72 | 73 | Using this mutation function in a React component looks like: 74 | 75 | ```ts 76 | const mutation = useMutation(api.functions.myMutationFunction); 77 | function handleButtonPress() { 78 | // fire and forget, the most common way to use mutations 79 | mutation({ first: "Hello!", second: "me" }); 80 | // OR 81 | // use the result once the mutation has completed 82 | mutation({ first: "Hello!", second: "me" }).then((result) => 83 | console.log(result) 84 | ); 85 | } 86 | ``` 87 | 88 | Use the Convex CLI to push your functions to a deployment. See everything 89 | the Convex CLI can do by running `npx convex -h` in your project root 90 | directory. To learn more, launch the docs with `npx convex docs`. 91 | -------------------------------------------------------------------------------- /convex/_generated/api.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /** 3 | * Generated `api` utility. 4 | * 5 | * THIS CODE IS AUTOMATICALLY GENERATED. 6 | * 7 | * Generated by convex@1.10.0. 8 | * To regenerate, run `npx convex dev`. 9 | * @module 10 | */ 11 | 12 | import type { 13 | ApiFromModules, 14 | FilterApi, 15 | FunctionReference, 16 | } from "convex/server"; 17 | import type * as chat from "../chat.js"; 18 | import type * as triviaGames from "../triviaGames.js"; 19 | import type * as triviaQuestions from "../triviaQuestions.js"; 20 | import type * as types from "../types.js"; 21 | import type * as users from "../users.js"; 22 | 23 | /** 24 | * A utility for referencing Convex functions in your app's API. 25 | * 26 | * Usage: 27 | * ```js 28 | * const myFunctionReference = api.myModule.myFunction; 29 | * ``` 30 | */ 31 | declare const fullApi: ApiFromModules<{ 32 | chat: typeof chat; 33 | triviaGames: typeof triviaGames; 34 | triviaQuestions: typeof triviaQuestions; 35 | types: typeof types; 36 | users: typeof users; 37 | }>; 38 | export declare const api: FilterApi< 39 | typeof fullApi, 40 | FunctionReference 41 | >; 42 | export declare const internal: FilterApi< 43 | typeof fullApi, 44 | FunctionReference 45 | >; 46 | -------------------------------------------------------------------------------- /convex/_generated/api.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /** 3 | * Generated `api` utility. 4 | * 5 | * THIS CODE IS AUTOMATICALLY GENERATED. 6 | * 7 | * Generated by convex@1.10.0. 8 | * To regenerate, run `npx convex dev`. 9 | * @module 10 | */ 11 | 12 | import { anyApi } from "convex/server"; 13 | 14 | /** 15 | * A utility for referencing Convex functions in your app's API. 16 | * 17 | * Usage: 18 | * ```js 19 | * const myFunctionReference = api.myModule.myFunction; 20 | * ``` 21 | */ 22 | export const api = anyApi; 23 | export const internal = anyApi; 24 | -------------------------------------------------------------------------------- /convex/_generated/dataModel.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /** 3 | * Generated data model types. 4 | * 5 | * THIS CODE IS AUTOMATICALLY GENERATED. 6 | * 7 | * Generated by convex@1.10.0. 8 | * To regenerate, run `npx convex dev`. 9 | * @module 10 | */ 11 | 12 | import type { 13 | DataModelFromSchemaDefinition, 14 | DocumentByName, 15 | TableNamesInDataModel, 16 | SystemTableNames, 17 | } from "convex/server"; 18 | import type { GenericId } from "convex/values"; 19 | import schema from "../schema.js"; 20 | 21 | /** 22 | * The names of all of your Convex tables. 23 | */ 24 | export type TableNames = TableNamesInDataModel; 25 | 26 | /** 27 | * The type of a document stored in Convex. 28 | * 29 | * @typeParam TableName - A string literal type of the table name (like "users"). 30 | */ 31 | export type Doc = DocumentByName< 32 | DataModel, 33 | TableName 34 | >; 35 | 36 | /** 37 | * An identifier for a document in Convex. 38 | * 39 | * Convex documents are uniquely identified by their `Id`, which is accessible 40 | * on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids). 41 | * 42 | * Documents can be loaded using `db.get(id)` in query and mutation functions. 43 | * 44 | * IDs are just strings at runtime, but this type can be used to distinguish them from other 45 | * strings when type checking. 46 | * 47 | * @typeParam TableName - A string literal type of the table name (like "users"). 48 | */ 49 | export type Id = 50 | GenericId; 51 | 52 | /** 53 | * A type describing your Convex data model. 54 | * 55 | * This type includes information about what tables you have, the type of 56 | * documents stored in those tables, and the indexes defined on them. 57 | * 58 | * This type is used to parameterize methods like `queryGeneric` and 59 | * `mutationGeneric` to make them type-safe. 60 | */ 61 | export type DataModel = DataModelFromSchemaDefinition; 62 | -------------------------------------------------------------------------------- /convex/_generated/server.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /** 3 | * Generated utilities for implementing server-side Convex query and mutation functions. 4 | * 5 | * THIS CODE IS AUTOMATICALLY GENERATED. 6 | * 7 | * Generated by convex@1.10.0. 8 | * To regenerate, run `npx convex dev`. 9 | * @module 10 | */ 11 | 12 | import { 13 | ActionBuilder, 14 | HttpActionBuilder, 15 | MutationBuilder, 16 | QueryBuilder, 17 | GenericActionCtx, 18 | GenericMutationCtx, 19 | GenericQueryCtx, 20 | GenericDatabaseReader, 21 | GenericDatabaseWriter, 22 | } from "convex/server"; 23 | import type { DataModel } from "./dataModel.js"; 24 | 25 | /** 26 | * Define a query in this Convex app's public API. 27 | * 28 | * This function will be allowed to read your Convex database and will be accessible from the client. 29 | * 30 | * @param func - The query function. It receives a {@link QueryCtx} as its first argument. 31 | * @returns The wrapped query. Include this as an `export` to name it and make it accessible. 32 | */ 33 | export declare const query: QueryBuilder; 34 | 35 | /** 36 | * Define a query that is only accessible from other Convex functions (but not from the client). 37 | * 38 | * This function will be allowed to read from your Convex database. It will not be accessible from the client. 39 | * 40 | * @param func - The query function. It receives a {@link QueryCtx} as its first argument. 41 | * @returns The wrapped query. Include this as an `export` to name it and make it accessible. 42 | */ 43 | export declare const internalQuery: QueryBuilder; 44 | 45 | /** 46 | * Define a mutation in this Convex app's public API. 47 | * 48 | * This function will be allowed to modify your Convex database and will be accessible from the client. 49 | * 50 | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. 51 | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. 52 | */ 53 | export declare const mutation: MutationBuilder; 54 | 55 | /** 56 | * Define a mutation that is only accessible from other Convex functions (but not from the client). 57 | * 58 | * This function will be allowed to modify your Convex database. It will not be accessible from the client. 59 | * 60 | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. 61 | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. 62 | */ 63 | export declare const internalMutation: MutationBuilder; 64 | 65 | /** 66 | * Define an action in this Convex app's public API. 67 | * 68 | * An action is a function which can execute any JavaScript code, including non-deterministic 69 | * code and code with side-effects, like calling third-party services. 70 | * They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive. 71 | * They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}. 72 | * 73 | * @param func - The action. It receives an {@link ActionCtx} as its first argument. 74 | * @returns The wrapped action. Include this as an `export` to name it and make it accessible. 75 | */ 76 | export declare const action: ActionBuilder; 77 | 78 | /** 79 | * Define an action that is only accessible from other Convex functions (but not from the client). 80 | * 81 | * @param func - The function. It receives an {@link ActionCtx} as its first argument. 82 | * @returns The wrapped function. Include this as an `export` to name it and make it accessible. 83 | */ 84 | export declare const internalAction: ActionBuilder; 85 | 86 | /** 87 | * Define an HTTP action. 88 | * 89 | * This function will be used to respond to HTTP requests received by a Convex 90 | * deployment if the requests matches the path and method where this action 91 | * is routed. Be sure to route your action in `convex/http.js`. 92 | * 93 | * @param func - The function. It receives an {@link ActionCtx} as its first argument. 94 | * @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up. 95 | */ 96 | export declare const httpAction: HttpActionBuilder; 97 | 98 | /** 99 | * A set of services for use within Convex query functions. 100 | * 101 | * The query context is passed as the first argument to any Convex query 102 | * function run on the server. 103 | * 104 | * This differs from the {@link MutationCtx} because all of the services are 105 | * read-only. 106 | */ 107 | export type QueryCtx = GenericQueryCtx; 108 | 109 | /** 110 | * A set of services for use within Convex mutation functions. 111 | * 112 | * The mutation context is passed as the first argument to any Convex mutation 113 | * function run on the server. 114 | */ 115 | export type MutationCtx = GenericMutationCtx; 116 | 117 | /** 118 | * A set of services for use within Convex action functions. 119 | * 120 | * The action context is passed as the first argument to any Convex action 121 | * function run on the server. 122 | */ 123 | export type ActionCtx = GenericActionCtx; 124 | 125 | /** 126 | * An interface to read from the database within Convex query functions. 127 | * 128 | * The two entry points are {@link DatabaseReader.get}, which fetches a single 129 | * document by its {@link Id}, or {@link DatabaseReader.query}, which starts 130 | * building a query. 131 | */ 132 | export type DatabaseReader = GenericDatabaseReader; 133 | 134 | /** 135 | * An interface to read from and write to the database within Convex mutation 136 | * functions. 137 | * 138 | * Convex guarantees that all writes within a single mutation are 139 | * executed atomically, so you never have to worry about partial writes leaving 140 | * your data in an inconsistent state. See [the Convex Guide](https://docs.convex.dev/understanding/convex-fundamentals/functions#atomicity-and-optimistic-concurrency-control) 141 | * for the guarantees Convex provides your functions. 142 | */ 143 | export type DatabaseWriter = GenericDatabaseWriter; 144 | -------------------------------------------------------------------------------- /convex/_generated/server.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /** 3 | * Generated utilities for implementing server-side Convex query and mutation functions. 4 | * 5 | * THIS CODE IS AUTOMATICALLY GENERATED. 6 | * 7 | * Generated by convex@1.10.0. 8 | * To regenerate, run `npx convex dev`. 9 | * @module 10 | */ 11 | 12 | import { 13 | actionGeneric, 14 | httpActionGeneric, 15 | queryGeneric, 16 | mutationGeneric, 17 | internalActionGeneric, 18 | internalMutationGeneric, 19 | internalQueryGeneric, 20 | } from "convex/server"; 21 | 22 | /** 23 | * Define a query in this Convex app's public API. 24 | * 25 | * This function will be allowed to read your Convex database and will be accessible from the client. 26 | * 27 | * @param func - The query function. It receives a {@link QueryCtx} as its first argument. 28 | * @returns The wrapped query. Include this as an `export` to name it and make it accessible. 29 | */ 30 | export const query = queryGeneric; 31 | 32 | /** 33 | * Define a query that is only accessible from other Convex functions (but not from the client). 34 | * 35 | * This function will be allowed to read from your Convex database. It will not be accessible from the client. 36 | * 37 | * @param func - The query function. It receives a {@link QueryCtx} as its first argument. 38 | * @returns The wrapped query. Include this as an `export` to name it and make it accessible. 39 | */ 40 | export const internalQuery = internalQueryGeneric; 41 | 42 | /** 43 | * Define a mutation in this Convex app's public API. 44 | * 45 | * This function will be allowed to modify your Convex database and will be accessible from the client. 46 | * 47 | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. 48 | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. 49 | */ 50 | export const mutation = mutationGeneric; 51 | 52 | /** 53 | * Define a mutation that is only accessible from other Convex functions (but not from the client). 54 | * 55 | * This function will be allowed to modify your Convex database. It will not be accessible from the client. 56 | * 57 | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. 58 | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. 59 | */ 60 | export const internalMutation = internalMutationGeneric; 61 | 62 | /** 63 | * Define an action in this Convex app's public API. 64 | * 65 | * An action is a function which can execute any JavaScript code, including non-deterministic 66 | * code and code with side-effects, like calling third-party services. 67 | * They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive. 68 | * They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}. 69 | * 70 | * @param func - The action. It receives an {@link ActionCtx} as its first argument. 71 | * @returns The wrapped action. Include this as an `export` to name it and make it accessible. 72 | */ 73 | export const action = actionGeneric; 74 | 75 | /** 76 | * Define an action that is only accessible from other Convex functions (but not from the client). 77 | * 78 | * @param func - The function. It receives an {@link ActionCtx} as its first argument. 79 | * @returns The wrapped function. Include this as an `export` to name it and make it accessible. 80 | */ 81 | export const internalAction = internalActionGeneric; 82 | 83 | /** 84 | * Define a Convex HTTP action. 85 | * 86 | * @param func - The function. It receives an {@link ActionCtx} as its first argument, and a `Request` object 87 | * as its second. 88 | * @returns The wrapped endpoint function. Route a URL path to this function in `convex/http.js`. 89 | */ 90 | export const httpAction = httpActionGeneric; 91 | -------------------------------------------------------------------------------- /convex/auth.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | providers: [ 3 | { 4 | domain: "https://united-wolf-23.clerk.accounts.dev", 5 | applicationID: "convex", 6 | }, 7 | ], 8 | }; 9 | -------------------------------------------------------------------------------- /convex/chat.ts: -------------------------------------------------------------------------------- 1 | import { v } from 'convex/values'; 2 | import { mutation, query } from './_generated/server'; 3 | 4 | export const sendMessage = mutation({ 5 | args: { 6 | userId: v.string(), 7 | username: v.string(), 8 | content: v.string(), 9 | }, 10 | handler: async (ctx, args) => { 11 | const messageId = await ctx.db.insert('chatMessages', { 12 | userId: args.userId, 13 | username: args.username, 14 | content: args.content, 15 | timestamp: new Date().toISOString(), 16 | }); 17 | return messageId; 18 | }, 19 | }); 20 | 21 | export const getMessages = query({ 22 | args: {}, 23 | handler: async (ctx) => { 24 | const thirtyMinutesAgo = new Date(Date.now() - 30 * 60 * 1000).toISOString(); 25 | return await ctx.db 26 | .query('chatMessages') 27 | .filter(q => q.gt(q.field('timestamp'), thirtyMinutesAgo)) 28 | .order('desc') 29 | .take(50); 30 | }, 31 | }); -------------------------------------------------------------------------------- /convex/myFunctions.ts: -------------------------------------------------------------------------------- 1 | // import { v } from "convex/values"; 2 | // import { query, mutation, action } from "./_generated/server"; 3 | // import { api } from "./_generated/api"; 4 | 5 | // // Write your Convex functions in any file inside this directory (`convex`). 6 | // // See https://docs.convex.dev/functions for more. 7 | 8 | // // You can read data from the database via a query: 9 | // export const listNumbers = query({ 10 | // // Validators for arguments. 11 | // args: { 12 | // count: v.number(), 13 | // }, 14 | 15 | // // Query implementation. 16 | // handler: async (ctx, args) => { 17 | // //// Read the database as many times as you need here. 18 | // //// See https://docs.convex.dev/database/reading-data. 19 | // const numbers = await ctx.db 20 | // .query("numbers") 21 | // // Ordered by _creationTime, return most recent 22 | // .order("desc") 23 | // .take(args.count); 24 | // return { 25 | // viewer: (await ctx.auth.getUserIdentity())?.name, 26 | // numbers: numbers.toReversed().map((number) => number.value), 27 | // }; 28 | // }, 29 | // }); 30 | 31 | // // You can write data to the database via a mutation: 32 | // export const addNumber = mutation({ 33 | // // Validators for arguments. 34 | // args: { 35 | // value: v.number(), 36 | // }, 37 | 38 | // // Mutation implementation. 39 | // handler: async (ctx, args) => { 40 | // //// Insert or modify documents in the database here. 41 | // //// Mutations can also read from the database like queries. 42 | // //// See https://docs.convex.dev/database/writing-data. 43 | 44 | // const id = await ctx.db.insert("numbers", { value: args.value }); 45 | 46 | // console.log("Added new document with id:", id); 47 | // // Optionally, return a value from your mutation. 48 | // // return id; 49 | // }, 50 | // }); 51 | 52 | // // You can fetch data from and send data to third-party APIs via an action: 53 | // export const myAction = action({ 54 | // // Validators for arguments. 55 | // args: { 56 | // first: v.number(), 57 | // second: v.string(), 58 | // }, 59 | 60 | // // Action implementation. 61 | // handler: async (ctx, args) => { 62 | // //// Use the browser-like `fetch` API to send HTTP requests. 63 | // //// See https://docs.convex.dev/functions/actions#calling-third-party-apis-and-using-npm-packages. 64 | // // const response = await ctx.fetch("https://api.thirdpartyservice.com"); 65 | // // const data = await response.json(); 66 | 67 | // //// Query data by running Convex queries. 68 | // const data = await ctx.runQuery(api.myFunctions.listNumbers, { 69 | // count: 10, 70 | // }); 71 | // console.log(data); 72 | 73 | // //// Write data by running Convex mutations. 74 | // await ctx.runMutation(api.myFunctions.addNumber, { 75 | // value: args.first, 76 | // }); 77 | // }, 78 | // }); 79 | -------------------------------------------------------------------------------- /convex/schema.ts: -------------------------------------------------------------------------------- 1 | // NOTE: You can remove this file. Declaring the shape 2 | // of the database is entirely optional in Convex. 3 | // See https://docs.convex.dev/database/schemas. 4 | 5 | import { defineSchema, defineTable } from "convex/server"; 6 | import { v } from "convex/values"; 7 | 8 | export default defineSchema({ 9 | users: defineTable({ 10 | name: v.string(), 11 | profileUrl: v.optional(v.string()), 12 | tokenIdentifier: v.string(), 13 | }).index("by_token", ["tokenIdentifier"]), 14 | 15 | triviaQuestions: defineTable({ 16 | questionText: v.string(), 17 | choiceA: v.string(), 18 | choiceB: v.string(), 19 | choiceC: v.string(), 20 | choiceD: v.string(), 21 | correctChoice: v.string(), 22 | maxPoints: v.number(), 23 | }), 24 | 25 | triviaGames: defineTable({ 26 | status: v.string(), // "waiting", "in_progress", "finished" 27 | hostUserId: v.id("users"), 28 | startDateTime: v.optional(v.string()), 29 | endDateTime: v.optional(v.string()), 30 | currentQuestionIndex: v.number(), 31 | triviaQuestionIds: v.array(v.id("triviaQuestions")), 32 | weekNumber: v.number(), 33 | questionStartedAt: v.number(), 34 | isInReviewPhase: v.boolean(), 35 | }).index("by_status", ["status"]), 36 | 37 | triviaParticipants: defineTable({ 38 | userId: v.id("users"), 39 | gameId: v.id("triviaGames"), 40 | name: v.string(), 41 | score: v.number(), 42 | answers: v.array(v.object({ 43 | questionId: v.id("triviaQuestions"), 44 | answerSubmitted: v.string(), 45 | timeRemaining: v.number(), 46 | pointsEarned: v.number(), 47 | })), 48 | }).index("by_game", ["gameId"]) 49 | .index("by_user_and_game", ["userId", "gameId"]) 50 | .index("by_user", ["userId"]), 51 | 52 | chatMessages: defineTable({ 53 | userId: v.string(), 54 | username: v.string(), 55 | content: v.string(), 56 | timestamp: v.string(), 57 | }).index("by_game", ["timestamp"]), 58 | 59 | }, { schemaValidation: true }); -------------------------------------------------------------------------------- /convex/triviaGames.ts: -------------------------------------------------------------------------------- 1 | import { v } from "convex/values"; 2 | import { mutation, query } from "./_generated/server"; 3 | import { triviaQuestions } from './triviaQuestions'; 4 | import { TriviaQuestion } from './types'; 5 | 6 | // Only Forrest can create a game 7 | export const createTriviaGame = mutation({ 8 | args: {}, 9 | handler: async (ctx) => { 10 | const user = await ctx.auth.getUserIdentity(); 11 | if (!user) { 12 | throw new Error("Not authorized to create a game"); 13 | } 14 | 15 | const userData = await ctx.db 16 | .query("users") 17 | .withIndex("by_token", (q) => q.eq("tokenIdentifier", user.tokenIdentifier)) 18 | .unique(); 19 | 20 | if (!userData) { 21 | throw new Error("Not authorized to create a game"); 22 | } 23 | 24 | const questionIds = await Promise.all( 25 | triviaQuestions.map((question: TriviaQuestion) => ctx.db.insert("triviaQuestions", question)) 26 | ); 27 | 28 | const gameId = await ctx.db.insert("triviaGames", { 29 | status: "waiting", 30 | hostUserId: userData._id, 31 | startDateTime: undefined, 32 | endDateTime: undefined, 33 | currentQuestionIndex: 0, 34 | triviaQuestionIds: questionIds, 35 | weekNumber: Math.floor(Date.now() / (7 * 24 * 60 * 60 * 1000)), 36 | questionStartedAt: 0, 37 | isInReviewPhase: false, 38 | }); 39 | 40 | await ctx.db.insert("triviaParticipants", { 41 | userId: userData._id, 42 | gameId: gameId, 43 | name: userData.name, 44 | score: 0, 45 | answers: [], 46 | }); 47 | 48 | return gameId; 49 | }, 50 | }); 51 | 52 | export const getAvailableGame = query({ 53 | args: {}, 54 | handler: async (ctx) => { 55 | const availableGame = await ctx.db 56 | .query("triviaGames") 57 | .filter(q => q.eq(q.field("status"), "waiting")) 58 | .first(); 59 | 60 | return availableGame; 61 | }, 62 | }); 63 | 64 | export const joinTriviaGame = mutation({ 65 | args: { gameId: v.id("triviaGames") }, 66 | handler: async (ctx, args) => { 67 | const identity = await ctx.auth.getUserIdentity(); 68 | if (!identity) { 69 | throw new Error("Not authenticated"); 70 | } 71 | 72 | const user = await ctx.db 73 | .query("users") 74 | .withIndex("by_token", (q) => q.eq("tokenIdentifier", identity.tokenIdentifier)) 75 | .unique(); 76 | 77 | if (!user) { 78 | throw new Error("User not found"); 79 | } 80 | 81 | const game = await ctx.db.get(args.gameId); 82 | if (!game || game.status !== "waiting") { 83 | throw new Error("Game not available for joining"); 84 | } 85 | 86 | // Check if the user has already joined 87 | const existingParticipant = await ctx.db 88 | .query("triviaParticipants") 89 | .withIndex("by_user_and_game", (q) => 90 | q.eq("userId", user._id).eq("gameId", args.gameId) 91 | ) 92 | .unique(); 93 | 94 | if (existingParticipant) { 95 | return { success: true }; 96 | } 97 | 98 | await ctx.db.insert("triviaParticipants", { 99 | userId: user._id, 100 | gameId: args.gameId, 101 | name: user.name, 102 | score: 0, 103 | answers: [], 104 | }); 105 | 106 | return { success: true }; 107 | }, 108 | }); 109 | 110 | export const getTriviaGame = query({ 111 | args: { gameId: v.id("triviaGames") }, 112 | handler: async (ctx, args) => { 113 | const game = await ctx.db.get(args.gameId); 114 | if (!game) { 115 | return null; 116 | } 117 | 118 | const questions = await Promise.all( 119 | game.triviaQuestionIds.map(async (id) => { 120 | const question = await ctx.db.get(id); 121 | return question; 122 | }) 123 | ); 124 | 125 | const participants = await ctx.db 126 | .query("triviaParticipants") 127 | .withIndex("by_game", (q) => q.eq("gameId", args.gameId)) 128 | .collect(); 129 | 130 | return { game, questions, participants }; 131 | }, 132 | }); 133 | 134 | export const startTriviaGame = mutation({ 135 | args: { gameId: v.id("triviaGames") }, 136 | handler: async (ctx, args) => { 137 | 138 | const identity = await ctx.auth.getUserIdentity(); 139 | if (!identity) { 140 | throw new Error("Not authenticated"); 141 | } 142 | 143 | const user = await ctx.db 144 | .query("users") 145 | .withIndex("by_token", (q) => q.eq("tokenIdentifier", identity.tokenIdentifier)) 146 | .unique(); 147 | 148 | if (!user) { 149 | throw new Error("User not found"); 150 | } 151 | 152 | const game = await ctx.db.get(args.gameId); 153 | if (!game) { 154 | throw new Error("Game not found"); 155 | } 156 | 157 | if (game.hostUserId !== user._id) { 158 | throw new Error("Not authorized to start the game"); 159 | } 160 | 161 | if (game.status !== "waiting") { 162 | throw new Error(`Cannot start game with status: ${game.status}`); 163 | } 164 | 165 | const startDateTime = new Date(); 166 | const endDateTime = new Date(startDateTime.getTime() + game.triviaQuestionIds.length * (20 + 3) * 1000); // amount of questions, 20 seconds each, 3 second review phase 167 | 168 | await ctx.db.patch(args.gameId, { 169 | status: "in_progress", 170 | startDateTime: startDateTime.toISOString(), 171 | endDateTime: endDateTime.toISOString(), 172 | questionStartedAt: Date.now(), 173 | isInReviewPhase: false, 174 | }); 175 | return { success: true, gameId: args.gameId }; 176 | }, 177 | }); 178 | 179 | export const moveToReviewPhase = mutation({ 180 | args: { gameId: v.id("triviaGames") }, 181 | handler: async (ctx, args) => { 182 | const identity = await ctx.auth.getUserIdentity(); 183 | if (!identity) { 184 | throw new Error("Not authenticated"); 185 | } 186 | 187 | const user = await ctx.db 188 | .query("users") 189 | .withIndex("by_token", q => q.eq("tokenIdentifier", identity.tokenIdentifier)) 190 | .unique(); 191 | 192 | const game = await ctx.db.get(args.gameId); 193 | if (!game || game.status !== "in_progress") { 194 | throw new Error("Game is not in progress"); 195 | } 196 | 197 | if (!user || user._id !== game.hostUserId) { 198 | throw new Error("Only host can move to next question"); 199 | } 200 | 201 | await ctx.db.patch(args.gameId, { 202 | isInReviewPhase: true, 203 | questionStartedAt: Date.now(), 204 | }) 205 | 206 | return { success: true }; 207 | } 208 | }) 209 | 210 | export const submitAnswer = mutation({ 211 | args: { 212 | gameId: v.id("triviaGames"), 213 | questionId: v.id("triviaQuestions"), 214 | answer: v.string(), 215 | timeRemaining: v.number(), 216 | }, 217 | handler: async (ctx, args) => { 218 | const identity = await ctx.auth.getUserIdentity(); 219 | if (!identity) { 220 | throw new Error("Not authenticated"); 221 | } 222 | 223 | const user = await ctx.db 224 | .query("users") 225 | .withIndex("by_token", (q) => q.eq("tokenIdentifier", identity.tokenIdentifier)) 226 | .unique(); 227 | 228 | if (!user) { 229 | throw new Error("User not found"); 230 | } 231 | 232 | const game = await ctx.db.get(args.gameId); 233 | if (!game || game.status !== "in_progress") { 234 | throw new Error("Game is not in progress"); 235 | } 236 | 237 | const question = await ctx.db.get(args.questionId); 238 | if (!question) { 239 | throw new Error("Question not found"); 240 | } 241 | 242 | let participant = await ctx.db 243 | .query("triviaParticipants") 244 | .withIndex("by_user_and_game", q => 245 | q.eq("userId", user._id) 246 | .eq("gameId", args.gameId) 247 | ) 248 | .unique(); 249 | 250 | if (!participant) { 251 | // If participant is not found, create a new one 252 | const participantId = await ctx.db.insert("triviaParticipants", { 253 | userId: user._id, 254 | gameId: args.gameId, 255 | name: user.name, 256 | score: 0, 257 | answers: [], 258 | }); 259 | participant = await ctx.db.get(participantId); 260 | } 261 | 262 | if (!participant) { 263 | throw new Error("Failed to create or find participant"); 264 | } 265 | 266 | // Check if the answer for this question has already been submitted 267 | const existingAnswer = participant.answers.find(a => a.questionId === args.questionId); 268 | if (existingAnswer) { 269 | return { success: true, pointsEarned: existingAnswer.pointsEarned, message: "Answer already submitted" }; 270 | } 271 | 272 | const isCorrect = question.correctChoice === args.answer; 273 | const pointsEarned = isCorrect ? Math.round(args.timeRemaining) : 0; 274 | 275 | await ctx.db.patch(participant._id, { 276 | score: (participant.score || 0) + pointsEarned, 277 | answers: [ 278 | ...(participant.answers || []), 279 | { 280 | questionId: args.questionId, 281 | answerSubmitted: args.answer, 282 | timeRemaining: args.timeRemaining, 283 | pointsEarned, 284 | }, 285 | ], 286 | }); 287 | 288 | return { success: true, pointsEarned }; 289 | }, 290 | }); 291 | 292 | export const moveToNextQuestion = mutation({ 293 | args: { gameId: v.id("triviaGames") }, 294 | handler: async (ctx, args) => { 295 | 296 | const identity = await ctx.auth.getUserIdentity(); 297 | if (!identity) throw new Error("Not authenticated"); 298 | 299 | const user = await ctx.db 300 | .query("users") 301 | .withIndex("by_token", q => q.eq("tokenIdentifier", identity.tokenIdentifier)) 302 | .unique(); 303 | 304 | const game = await ctx.db.get(args.gameId); 305 | if (!game || game.status !== "in_progress") { 306 | throw new Error("Game is not in progress"); 307 | } 308 | 309 | if (!user || user._id !== game.hostUserId) { 310 | throw new Error("Only host can move to next question"); 311 | } 312 | 313 | const nextQuestionIndex = game.currentQuestionIndex + 1; 314 | 315 | if (nextQuestionIndex >= game.triviaQuestionIds.length) { 316 | await ctx.db.patch(args.gameId, { 317 | status: "finished", 318 | currentQuestionIndex: nextQuestionIndex - 1 319 | }); 320 | return { gameFinished: true, newQuestionIndex: nextQuestionIndex - 1 }; 321 | } 322 | 323 | await ctx.db.patch(args.gameId, { 324 | currentQuestionIndex: nextQuestionIndex, 325 | questionStartedAt: Date.now(), 326 | isInReviewPhase: false, 327 | }); 328 | 329 | return { success: true, newQuestionIndex: nextQuestionIndex }; 330 | }, 331 | }); 332 | 333 | export const getLeaderboard = query({ 334 | args: { gameId: v.id("triviaGames") }, 335 | handler: async (ctx, args) => { 336 | const participants = await ctx.db 337 | .query("triviaParticipants") 338 | .withIndex("by_game", q => q.eq("gameId", args.gameId)) 339 | .collect(); 340 | 341 | const leaderboard = await Promise.all( 342 | participants.map(async participant => { 343 | const user = await ctx.db.get(participant.userId); 344 | return { 345 | name: user?.name, 346 | score: participant.score, 347 | }; 348 | }) 349 | ); 350 | 351 | return leaderboard.sort((a, b) => b.score - a.score); 352 | }, 353 | }); 354 | 355 | export const getMostRecentGameLeaderboard = query({ 356 | args: {}, 357 | handler: async (ctx) => { 358 | const mostRecentGame = await ctx.db 359 | .query("triviaGames") 360 | .filter((q) => q.eq(q.field("status"), "finished")) 361 | .order("desc") 362 | .first(); 363 | 364 | if (!mostRecentGame) { 365 | return null; 366 | } 367 | 368 | const participants = await ctx.db 369 | .query("triviaParticipants") 370 | .withIndex("by_game", (q) => q.eq("gameId", mostRecentGame._id)) 371 | .collect(); 372 | 373 | const leaderboard = participants 374 | .map((participant) => ({ 375 | name: participant.name, 376 | score: participant.score, 377 | })) 378 | .sort((a, b) => b.score - a.score); 379 | 380 | return leaderboard; 381 | }, 382 | }); 383 | 384 | export const getCurrentGame = query({ 385 | args: {}, 386 | handler: async (ctx) => { 387 | const identity = await ctx.auth.getUserIdentity(); 388 | if (!identity) return null; 389 | 390 | const user = await ctx.db 391 | .query('users') 392 | .withIndex('by_token', (q) => q.eq('tokenIdentifier', identity.tokenIdentifier)) 393 | .first(); 394 | 395 | if (!user) return null; 396 | 397 | const participations = await ctx.db 398 | .query('triviaParticipants') 399 | .withIndex('by_user', (q) => q.eq('userId', user._id)) 400 | .collect(); 401 | 402 | for (const participation of participations) { 403 | const game = await ctx.db.get(participation.gameId); 404 | if (game && (game.status === 'waiting' || game.status === 'in_progress')) { 405 | return game._id; 406 | } 407 | } 408 | return null; 409 | }, 410 | }); -------------------------------------------------------------------------------- /convex/triviaQuestions.ts: -------------------------------------------------------------------------------- 1 | import { TriviaQuestion } from './types'; 2 | 3 | export let triviaQuestions: TriviaQuestion[] = []; 4 | 5 | if (typeof process !== 'undefined' && process.env.TRIVIA_QUESTIONS) { 6 | try { 7 | triviaQuestions = JSON.parse(process.env.TRIVIA_QUESTIONS); 8 | } catch (error) { 9 | console.error('Error parsing TRIVIA_QUESTIONS:', error); 10 | } 11 | } -------------------------------------------------------------------------------- /convex/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | /* This TypeScript project config describes the environment that 3 | * Convex functions run in and is used to typecheck them. 4 | * You can modify it, but some settings required to use Convex. 5 | */ 6 | "compilerOptions": { 7 | /* These settings are not required by Convex and can be modified. */ 8 | "allowJs": true, 9 | "strict": true, 10 | "skipLibCheck": true, 11 | 12 | /* These compiler options are required by Convex */ 13 | "target": "ESNext", 14 | "lib": ["ES2021", "dom", "ESNext.Array"], 15 | "forceConsistentCasingInFileNames": true, 16 | "allowSyntheticDefaultImports": true, 17 | "module": "ESNext", 18 | "moduleResolution": "Node", 19 | "isolatedModules": true, 20 | "noEmit": true 21 | }, 22 | "include": ["./**/*", "../questions.config.ts"], 23 | "exclude": ["./_generated"] 24 | } 25 | -------------------------------------------------------------------------------- /convex/types.ts: -------------------------------------------------------------------------------- 1 | export interface TriviaQuestion { 2 | questionText: string; 3 | choiceA: string; 4 | choiceB: string; 5 | choiceC: string; 6 | choiceD: string; 7 | correctChoice: string; 8 | maxPoints: number; 9 | } -------------------------------------------------------------------------------- /convex/users.ts: -------------------------------------------------------------------------------- 1 | import { v } from "convex/values"; 2 | import { mutation, query } from "./_generated/server"; 3 | 4 | export const store = mutation({ 5 | args: {}, 6 | handler: async (ctx) => { 7 | const identity = await ctx.auth.getUserIdentity(); 8 | if (!identity) { 9 | throw new Error("Called storeUser without authentication present"); 10 | } 11 | 12 | const user = await ctx.db 13 | .query("users") 14 | .withIndex("by_token", (q) => 15 | q.eq("tokenIdentifier", identity.tokenIdentifier) 16 | ) 17 | .unique(); 18 | 19 | if (user !== null) { 20 | if (user.name !== identity.name) { 21 | await ctx.db.patch(user._id, { name: identity.name }); 22 | } 23 | return user._id; 24 | } 25 | 26 | const newUser = await ctx.db.insert("users", { 27 | name: identity.name!, 28 | profileUrl: identity.pictureUrl, 29 | tokenIdentifier: identity.tokenIdentifier, 30 | }); 31 | return newUser; 32 | }, 33 | }); 34 | 35 | export const getUserHistories = query({ 36 | args: {}, 37 | handler: async (ctx) => { 38 | const identity = await ctx.auth.getUserIdentity(); 39 | if (!identity) { 40 | throw new Error("Not authenticated"); 41 | } 42 | 43 | const user = await ctx.db 44 | .query("users") 45 | .withIndex("by_token", (q) => q.eq("tokenIdentifier", identity.tokenIdentifier)) 46 | .unique(); 47 | 48 | if (!user) { 49 | throw new Error("User not found"); 50 | } 51 | 52 | const triviaParticipations = await ctx.db 53 | .query("triviaParticipants") 54 | .filter((q) => q.eq(q.field("userId"), user._id)) 55 | .order("desc") 56 | .collect(); 57 | 58 | return { triviaParticipations }; 59 | }, 60 | }); 61 | 62 | export const getUser = query({ 63 | args: { userId: v.string() }, 64 | handler: async (ctx, args) => { 65 | 66 | const users = await ctx.db 67 | .query("users") 68 | .filter((q) => 69 | q.or( 70 | q.eq(q.field("tokenIdentifier"), args.userId), 71 | q.eq(q.field("tokenIdentifier"), `https://united-wolf-23.clerk.accounts.dev|${args.userId}`) 72 | ) 73 | ) 74 | .collect(); 75 | 76 | const user = users[0]; 77 | 78 | return { user }; 79 | }, 80 | }); -------------------------------------------------------------------------------- /generate-questions-env.mjs: -------------------------------------------------------------------------------- 1 | import { existsSync, readFileSync, writeFileSync } from 'fs'; 2 | import { dirname, join } from 'path'; 3 | import { fileURLToPath } from 'url'; 4 | 5 | const __filename = fileURLToPath(import.meta.url); 6 | const __dirname = dirname(__filename); 7 | 8 | console.log('Starting generate-questions-env script'); 9 | 10 | try { 11 | let triviaQuestionsJson; 12 | 13 | // Check if questions.config.ts exists (local environment) 14 | const questionsConfigPath = join(__dirname, 'questions.config.ts'); 15 | if (existsSync(questionsConfigPath)) { 16 | console.log('questions.config.ts found, reading questions from file'); 17 | const questionsConfigContent = readFileSync(questionsConfigPath, 'utf8'); 18 | const match = questionsConfigContent.match(/export const triviaQuestions = (\[[\s\S]*?\]);/); 19 | 20 | if (match && match[1]) { 21 | triviaQuestionsJson = JSON.stringify(eval(match[1])); 22 | } else { 23 | throw new Error('Failed to extract triviaQuestions from questions.config.ts'); 24 | } 25 | } 26 | // Check if TRIVIA_QUESTIONS environment variable exists (Vercel environment) 27 | else if (process.env.TRIVIA_QUESTIONS) { 28 | console.log('TRIVIA_QUESTIONS environment variable found'); 29 | triviaQuestionsJson = process.env.TRIVIA_QUESTIONS; 30 | } 31 | else { 32 | throw new Error('Neither questions.config.ts nor TRIVIA_QUESTIONS environment variable found'); 33 | } 34 | 35 | // Create or update the .env file with the questions 36 | const envContent = `TRIVIA_QUESTIONS='${triviaQuestionsJson}'`; 37 | writeFileSync('.env', envContent); 38 | 39 | console.log('Successfully generated .env file with TRIVIA_QUESTIONS'); 40 | } catch (error) { 41 | console.error('Error generating TRIVIA_QUESTIONS:', error); 42 | process.exit(1); 43 | } -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Dev Trivia 9 | 10 | 11 |
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dev-trivia", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "npm-run-all --parallel dev:frontend dev:backend", 8 | "dev:frontend": "vite --open", 9 | "dev:backend": "convex dev", 10 | "predev": "convex dev --until-success && convex dashboard", 11 | "build": "tsc && vite build", 12 | "lint": "tsc && eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 13 | "preview": "vite preview", 14 | "generate-questions-env": "node generate-questions-env.mjs" 15 | }, 16 | "dependencies": { 17 | "@clerk/clerk-react": "^4.25.2", 18 | "@hookform/resolvers": "^3.3.2", 19 | "@radix-ui/react-accordion": "^1.1.2", 20 | "@radix-ui/react-alert-dialog": "^1.0.5", 21 | "@radix-ui/react-aspect-ratio": "^1.0.3", 22 | "@radix-ui/react-avatar": "^1.0.4", 23 | "@radix-ui/react-checkbox": "^1.0.4", 24 | "@radix-ui/react-collapsible": "^1.0.3", 25 | "@radix-ui/react-context-menu": "^2.1.5", 26 | "@radix-ui/react-dialog": "^1.0.5", 27 | "@radix-ui/react-dropdown-menu": "^2.0.6", 28 | "@radix-ui/react-hover-card": "^1.0.7", 29 | "@radix-ui/react-icons": "^1.3.0", 30 | "@radix-ui/react-label": "^2.0.2", 31 | "@radix-ui/react-menubar": "^1.0.4", 32 | "@radix-ui/react-navigation-menu": "^1.1.4", 33 | "@radix-ui/react-popover": "^1.0.7", 34 | "@radix-ui/react-progress": "^1.0.3", 35 | "@radix-ui/react-radio-group": "^1.1.3", 36 | "@radix-ui/react-scroll-area": "^1.0.5", 37 | "@radix-ui/react-select": "^2.0.0", 38 | "@radix-ui/react-separator": "^1.0.3", 39 | "@radix-ui/react-slider": "^1.1.2", 40 | "@radix-ui/react-slot": "^1.0.2", 41 | "@radix-ui/react-switch": "^1.0.3", 42 | "@radix-ui/react-tabs": "^1.0.4", 43 | "@radix-ui/react-toast": "^1.1.5", 44 | "@radix-ui/react-toggle": "^1.0.3", 45 | "@radix-ui/react-tooltip": "^1.0.7", 46 | "@vercel/analytics": "^1.3.1", 47 | "class-variance-authority": "^0.7.0", 48 | "clsx": "^2.0.0", 49 | "cmdk": "^0.2.0", 50 | "convex": "^1.10.0", 51 | "date-fns": "^2.30.0", 52 | "lucide-react": "^0.357.0", 53 | "obscenity": "^0.4.0", 54 | "react": "^18.2.0", 55 | "react-countup": "^6.5.0", 56 | "react-day-picker": "^8.9.1", 57 | "react-dom": "^18.2.0", 58 | "react-hook-form": "^7.47.0", 59 | "react-router-dom": "^6.22.3", 60 | "tailwind-merge": "^1.14.0", 61 | "tailwindcss-animate": "^1.0.7", 62 | "zod": "^3.22.4" 63 | }, 64 | "devDependencies": { 65 | "@types/node": "^20.7.0", 66 | "@types/react": "^18.2.21", 67 | "@types/react-dom": "^18.2.7", 68 | "@typescript-eslint/eslint-plugin": "^6.7.0", 69 | "@typescript-eslint/parser": "^6.7.0", 70 | "@vitejs/plugin-react": "^4.0.4", 71 | "autoprefixer": "^10.4.16", 72 | "eslint": "^8.49.0", 73 | "eslint-plugin-react-hooks": "^4.6.2", 74 | "eslint-plugin-react-refresh": "^0.4.3", 75 | "npm-run-all": "^4.1.5", 76 | "postcss": "^8.4.30", 77 | "tailwindcss": "^3.3.3", 78 | "typescript": "^5.2.2", 79 | "vite": "^4.4.9" 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /public/_redirects: -------------------------------------------------------------------------------- 1 | /* /index.html 200 2 | -------------------------------------------------------------------------------- /public/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /questions.config.template.ts: -------------------------------------------------------------------------------- 1 | export const triviaQuestions = [ 2 | { 3 | questionText: "Sample question?", 4 | choiceA: "Option A", 5 | choiceB: "Option B", 6 | choiceC: "Option C", 7 | choiceD: "Option D", 8 | correctChoice: "A", 9 | maxPoints: 10, 10 | }, 11 | // Add more questions as needed 12 | ]; -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import { useQuery } from "convex/react"; 2 | import CountUp from "react-countup"; 3 | import { api } from "../convex/_generated/api"; 4 | // import CountdownTimer from "./components/Countdown"; 5 | 6 | export default function App() { 7 | const recentLeaderboard = useQuery(api.triviaGames.getMostRecentGameLeaderboard); 8 | 9 | return ( 10 |
11 |
12 |

13 | Dev Trivia 14 |

15 |

16 | Test your coding knowledge and compete with fellow developers in our weekly live trivia game! Every Wednesday at 12pm ET, join us for 10 rounds of brain-teasing questions covering everything from programming languages to computer science fundamentals. Climb the leaderboard, learn something new, and have fun with the dev community! 17 |

18 |
19 | 20 |
21 | {/* */} 22 |
23 |
--- Next game starts in ---
24 |
25 |
26 |
December
27 |
28 |
29 |
I'm sorry, I'm just way too busy this month! - Forrest
30 |
31 |

32 | --- How It Works --- 33 |

34 |

35 | Dev Trivia is a weekly, live trivia game. All players will be asked the same question at the same time and given 20 seconds to answer it. Every correct answer scores points based on how much time is left in the round - the quicker you answer (correctly), the more points you get. Climb the leadserboard, compete with fellow developers, and have a good time doing it! 36 |

37 |
38 |
39 |
40 |

--- Last Week's Leaderboard ---

41 | {recentLeaderboard ? ( 42 | recentLeaderboard.map((participant, index) => ( 43 |
44 |
{index + 1})
45 |
46 |
47 | {participant.name} 48 |
49 |
50 |
51 | )) 52 | ) : ( 53 |

No recent games found.

54 | )} 55 |
56 |
57 |
58 | ); 59 | } 60 | -------------------------------------------------------------------------------- /src/ErrorBoundary.tsx: -------------------------------------------------------------------------------- 1 | import { Code } from "@/components/typography/code"; 2 | import { Link } from "@/components/typography/link"; 3 | import { Component, ReactNode } from "react"; 4 | 5 | // NOTE: Once you get Clerk working you can simplify this error boundary 6 | // or remove it entirely. 7 | export class ErrorBoundary extends Component< 8 | { children: ReactNode }, 9 | { error: ReactNode | null } 10 | > { 11 | constructor(props: { children: ReactNode }) { 12 | super(props); 13 | this.state = { error: null }; 14 | } 15 | 16 | static getDerivedStateFromError(error: unknown) { 17 | const errorText = "" + (error as any).toString(); 18 | if ( 19 | errorText.includes("@clerk/clerk-react") && 20 | errorText.includes("publishableKey") 21 | ) { 22 | const [clerkDashboardUrl] = errorText.match(/https:\S+/) ?? []; 23 | return { 24 | error: ( 25 | <> 26 |

27 | Add{" "} 28 | 29 | VITE_CLERK_PUBLISHABLE_KEY="{"<"}your publishable key{">"}" 30 | {" "} 31 | to the .env.local file 32 |

33 | {clerkDashboardUrl ? ( 34 |

35 | You can find it at{" "} 36 | 37 | {clerkDashboardUrl} 38 | 39 |

40 | ) : null} 41 |

Raw error: {errorText}

42 | 43 | ), 44 | }; 45 | } 46 | 47 | return { error:

{errorText}

}; 48 | } 49 | 50 | componentDidCatch() {} 51 | 52 | render() { 53 | if (this.state.error !== null) { 54 | return ( 55 |
56 |

57 | Caught an error while rendering: 58 |

59 | {this.state.error} 60 |
61 | ); 62 | } 63 | 64 | return this.props.children; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/components/AuthRoute.tsx: -------------------------------------------------------------------------------- 1 | import { useUser } from "@clerk/clerk-react"; 2 | import { ReactNode } from "react"; 3 | import { Navigate } from "react-router-dom"; 4 | 5 | export default function AuthRoute({ children }: { children: ReactNode }) { 6 | const { isSignedIn, isLoaded } = useUser(); 7 | 8 | if (isLoaded && !isSignedIn) return ; 9 | return children; 10 | } 11 | -------------------------------------------------------------------------------- /src/components/Chat.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from '@/components/ui/button'; 2 | import { Input } from '@/components/ui/input'; 3 | import { useUser } from '@clerk/clerk-react'; 4 | import { useMutation, useQuery } from 'convex/react'; 5 | import { RegExpMatcher, englishDataset, englishRecommendedTransformers } from 'obscenity'; 6 | import React, { useEffect, useRef, useState } from 'react'; 7 | import { api } from '../../convex/_generated/api'; 8 | 9 | const matcher = new RegExpMatcher({ 10 | ...englishDataset.build(), 11 | ...englishRecommendedTransformers, 12 | }); 13 | 14 | export const Chat: React.FC = () => { 15 | const { user } = useUser(); 16 | const [message, setMessage] = useState(''); 17 | const [error, setError] = useState(null); 18 | const chatContainerRef = useRef(null); 19 | 20 | const messages = useQuery(api.chat.getMessages); 21 | const sendMessage = useMutation(api.chat.sendMessage); 22 | 23 | useEffect(() => { 24 | if (chatContainerRef.current) { 25 | chatContainerRef.current.scrollTop = chatContainerRef.current.scrollHeight; 26 | } 27 | }, [messages]); 28 | 29 | useEffect(() => { 30 | let timer: NodeJS.Timeout; 31 | if (error) { 32 | timer = setTimeout(() => { 33 | setError(null); 34 | }, 3000); 35 | } 36 | return () => { 37 | if (timer) { 38 | clearTimeout(timer); 39 | } 40 | }; 41 | }, [error]); 42 | 43 | const handleSendMessage = async () => { 44 | if (message.trim() && user) { 45 | const trimmedMessage = message.trim(); 46 | const matches = matcher.getAllMatches(trimmedMessage); 47 | 48 | if (matches.length > 0) { 49 | setError("Your message contains inappropriate language. Please revise it."); 50 | return; 51 | } 52 | 53 | const username = user.username || user.firstName || user.id.slice(0, 8); 54 | 55 | await sendMessage({ 56 | userId: user.id, 57 | username, 58 | content: message.trim(), 59 | }); 60 | setMessage(''); 61 | } 62 | }; 63 | 64 | const filteredMessages = messages || []; 65 | 66 | return ( 67 |
68 |
Chat
69 |
73 |
74 | {filteredMessages.slice().reverse().map((msg, index) => ( 75 |
76 | {msg.username}: 77 | {msg.content} 78 |
79 | ))} 80 |
81 |
82 |
83 | {error &&
{error}
} 84 |
85 | setMessage(e.target.value)} 89 | onKeyPress={(e) => e.key === 'Enter' && handleSendMessage()} 90 | placeholder="Type a message..." 91 | className="flex-grow mr-2" 92 | /> 93 | 94 |
95 |
96 |
97 | ); 98 | }; 99 | 100 | export default Chat; -------------------------------------------------------------------------------- /src/components/Countdown.tsx: -------------------------------------------------------------------------------- 1 | import { Unauthenticated } from 'convex/react'; 2 | import React, { useEffect, useState } from 'react'; 3 | import { Link } from "react-router-dom"; 4 | 5 | interface TimeLeft { 6 | days: number; 7 | hours: number; 8 | minutes: number; 9 | seconds: number; 10 | } 11 | 12 | export type GameStatus = 'live' | 'about to start' | 'not started'; 13 | 14 | export function getNextWednesdayNoonET(): Date { 15 | const now = new Date(); 16 | const nextWednesday = new Date(now); 17 | nextWednesday.setDate(now.getDate() + ((3 - now.getDay() + 7) % 7)); 18 | nextWednesday.setUTCHours(17, 0, 0, 0); // Set to noon ET (16:00 UTC) 19 | 20 | // If it's already past this Wednesday noon, get next Wednesday 21 | if (nextWednesday <= now) { 22 | nextWednesday.setDate(nextWednesday.getDate() + 7); 23 | } 24 | 25 | return nextWednesday; 26 | } 27 | 28 | export function getGameStatus(): GameStatus { 29 | const now = new Date(); 30 | const nextWednesday = getNextWednesdayNoonET(); 31 | const difference = +nextWednesday - +now; 32 | const sevenDays = 7 * 24 * 60 * 60 * 1000; 33 | 34 | if (difference > 0 && difference <= 1800000) { 35 | return "about to start"; 36 | } else if (difference >= sevenDays - 1800000) { 37 | return "live"; 38 | } else { 39 | return "not started"; 40 | } 41 | } 42 | 43 | export function calculateTimeLeft(): TimeLeft { 44 | const now = new Date(); 45 | const nextWednesday = getNextWednesdayNoonET(); 46 | const difference = +nextWednesday - +now; 47 | 48 | const days = Math.floor(difference / (1000 * 60 * 60 * 24)); 49 | const hours = Math.floor((difference / (1000 * 60 * 60)) % 24); 50 | const minutes = Math.floor((difference / 1000 / 60) % 60); 51 | const seconds = Math.floor((difference / 1000) % 60); 52 | 53 | return { days, hours, minutes, seconds }; 54 | } 55 | 56 | interface CountdownProps { 57 | onGameStatusChange?: (status: GameStatus) => void; 58 | } 59 | 60 | const Countdown: React.FC = ({ onGameStatusChange }) => { 61 | const [timeLeft, setTimeLeft] = useState(calculateTimeLeft()); 62 | const [gameStatus, setGameStatus] = useState(getGameStatus()); 63 | 64 | useEffect(() => { 65 | const timer = setInterval(() => { 66 | const newTimeLeft = calculateTimeLeft(); 67 | setTimeLeft(newTimeLeft); 68 | 69 | const newGameStatus = getGameStatus(); 70 | if (newGameStatus !== gameStatus) { 71 | setGameStatus(newGameStatus); 72 | if (onGameStatusChange) { 73 | onGameStatusChange(newGameStatus); 74 | } 75 | } 76 | }, 1000); 77 | 78 | return () => clearInterval(timer); 79 | }, [gameStatus, onGameStatusChange]); 80 | 81 | const addLeadingZero = (value: number): string => { 82 | return value < 10 ? `0${value}` : value.toString(); 83 | } 84 | 85 | return ( 86 |
87 |
88 | {gameStatus === 'live' && "--- Game is live! ---"} 89 | {gameStatus === 'about to start' && "--- Game is about to start ---"} 90 | {gameStatus === 'not started' && "--- Next game starts in ---"} 91 |
92 |
93 |
94 | {(Object.keys(timeLeft) as Array).map((interval, index, array) => ( 95 | 96 |
97 |
98 | {addLeadingZero(timeLeft[interval])} 99 |
100 |
{interval}
101 |
102 | {index < array.length - 1 && ( 103 |
:
104 | )} 105 |
106 | ))} 107 |
108 |
109 | {(gameStatus === 'live' || gameStatus === 'about to start') && ( 110 | <> 111 | 115 |
Join the Game
116 | 117 | 118 |

You must be signed in to play.

119 |
120 | 121 | )} 122 |
123 | ); 124 | }; 125 | 126 | export default Countdown; 127 | 128 | // InlineCountdown component 129 | const InlineCountdown: React.FC = () => { 130 | const [timeLeft, setTimeLeft] = useState(calculateTimeLeft()); 131 | const [gameStatus, setGameStatus] = useState(getGameStatus()); 132 | 133 | useEffect(() => { 134 | const timer = setInterval(() => { 135 | const newTimeLeft = calculateTimeLeft(); 136 | setTimeLeft(newTimeLeft); 137 | 138 | const newGameStatus = getGameStatus(); 139 | if (newGameStatus !== gameStatus) { 140 | setGameStatus(newGameStatus); 141 | } 142 | }, 1000); 143 | 144 | return () => clearInterval(timer); 145 | }, [gameStatus]); 146 | 147 | if (gameStatus === 'live') { 148 | return The game is live now!; 149 | } 150 | 151 | if (gameStatus === 'about to start') { 152 | return {`${timeLeft.minutes}m ${timeLeft.seconds}s`}; 153 | } 154 | 155 | return ( 156 | 157 | {timeLeft.days}d {timeLeft.hours}h {timeLeft.minutes}m {timeLeft.seconds}s 158 | 159 | ); 160 | }; 161 | 162 | export { InlineCountdown }; 163 | -------------------------------------------------------------------------------- /src/components/Header.tsx: -------------------------------------------------------------------------------- 1 | import { SignInButton, UserButton } from "@clerk/clerk-react"; 2 | import { Authenticated, Unauthenticated } from "convex/react"; 3 | 4 | import { Link } from "react-router-dom"; 5 | import { Button } from "./ui/button"; 6 | 7 | export default function Header() { 8 | return ( 9 |
10 | 34 |
35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /src/components/layout/MainLayout.tsx: -------------------------------------------------------------------------------- 1 | import { Outlet, useLocation } from 'react-router-dom'; 2 | import Chat from '../Chat'; 3 | import Header from "../Header"; 4 | 5 | export default function MainLayout() { 6 | const location = useLocation(); 7 | const showChat = location.pathname.includes('trivia-lobby') || location.pathname.includes('trivia-game'); 8 | 9 | return ( 10 |
11 |
12 |
16 |
17 |
18 | 19 |
20 | {showChat && ( 21 |
22 |
23 | 24 |
25 |
26 | )} 27 |
28 |
29 |
30 | ); 31 | } -------------------------------------------------------------------------------- /src/components/typography/code.tsx: -------------------------------------------------------------------------------- 1 | import { se } from "@/lib/utils"; 2 | 3 | export const Code = se( 4 | "code", 5 | "relative rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono text-sm font-semibold" 6 | ); 7 | -------------------------------------------------------------------------------- /src/components/typography/link.tsx: -------------------------------------------------------------------------------- 1 | import { se } from "@/lib/utils"; 2 | import { AnchorHTMLAttributes } from "react"; 3 | 4 | export const Link = se< 5 | HTMLAnchorElement, 6 | AnchorHTMLAttributes 7 | >( 8 | "a", 9 | "font-medium text-primary underline underline-offset-4 hover:no-underline" 10 | ); 11 | -------------------------------------------------------------------------------- /src/components/ui/accordion.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as AccordionPrimitive from "@radix-ui/react-accordion" 3 | import { ChevronDownIcon } from "@radix-ui/react-icons" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const Accordion = AccordionPrimitive.Root 8 | 9 | const AccordionItem = React.forwardRef< 10 | React.ElementRef, 11 | React.ComponentPropsWithoutRef 12 | >(({ className, ...props }, ref) => ( 13 | 18 | )) 19 | AccordionItem.displayName = "AccordionItem" 20 | 21 | const AccordionTrigger = React.forwardRef< 22 | React.ElementRef, 23 | React.ComponentPropsWithoutRef 24 | >(({ className, children, ...props }, ref) => ( 25 | 26 | svg]:rotate-180", 30 | className 31 | )} 32 | {...props} 33 | > 34 | {children} 35 | 36 | 37 | 38 | )) 39 | AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName 40 | 41 | const AccordionContent = React.forwardRef< 42 | React.ElementRef, 43 | React.ComponentPropsWithoutRef 44 | >(({ className, children, ...props }, ref) => ( 45 | 53 |
{children}
54 |
55 | )) 56 | AccordionContent.displayName = AccordionPrimitive.Content.displayName 57 | 58 | export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } 59 | -------------------------------------------------------------------------------- /src/components/ui/alert-dialog.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" 3 | 4 | import { cn } from "@/lib/utils" 5 | import { buttonVariants } from "@/components/ui/button" 6 | 7 | const AlertDialog = AlertDialogPrimitive.Root 8 | 9 | const AlertDialogTrigger = AlertDialogPrimitive.Trigger 10 | 11 | const AlertDialogPortal = AlertDialogPrimitive.Portal 12 | 13 | const AlertDialogOverlay = React.forwardRef< 14 | React.ElementRef, 15 | React.ComponentPropsWithoutRef 16 | >(({ className, ...props }, ref) => ( 17 | 25 | )) 26 | AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName 27 | 28 | const AlertDialogContent = React.forwardRef< 29 | React.ElementRef, 30 | React.ComponentPropsWithoutRef 31 | >(({ className, ...props }, ref) => ( 32 | 33 | 34 | 42 | 43 | )) 44 | AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName 45 | 46 | const AlertDialogHeader = ({ 47 | className, 48 | ...props 49 | }: React.HTMLAttributes) => ( 50 |
57 | ) 58 | AlertDialogHeader.displayName = "AlertDialogHeader" 59 | 60 | const AlertDialogFooter = ({ 61 | className, 62 | ...props 63 | }: React.HTMLAttributes) => ( 64 |
71 | ) 72 | AlertDialogFooter.displayName = "AlertDialogFooter" 73 | 74 | const AlertDialogTitle = React.forwardRef< 75 | React.ElementRef, 76 | React.ComponentPropsWithoutRef 77 | >(({ className, ...props }, ref) => ( 78 | 83 | )) 84 | AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName 85 | 86 | const AlertDialogDescription = React.forwardRef< 87 | React.ElementRef, 88 | React.ComponentPropsWithoutRef 89 | >(({ className, ...props }, ref) => ( 90 | 95 | )) 96 | AlertDialogDescription.displayName = 97 | AlertDialogPrimitive.Description.displayName 98 | 99 | const AlertDialogAction = React.forwardRef< 100 | React.ElementRef, 101 | React.ComponentPropsWithoutRef 102 | >(({ className, ...props }, ref) => ( 103 | 108 | )) 109 | AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName 110 | 111 | const AlertDialogCancel = React.forwardRef< 112 | React.ElementRef, 113 | React.ComponentPropsWithoutRef 114 | >(({ className, ...props }, ref) => ( 115 | 124 | )) 125 | AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName 126 | 127 | export { 128 | AlertDialog, 129 | AlertDialogPortal, 130 | AlertDialogOverlay, 131 | AlertDialogTrigger, 132 | AlertDialogContent, 133 | AlertDialogHeader, 134 | AlertDialogFooter, 135 | AlertDialogTitle, 136 | AlertDialogDescription, 137 | AlertDialogAction, 138 | AlertDialogCancel, 139 | } 140 | -------------------------------------------------------------------------------- /src/components/ui/alert.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { cva, type VariantProps } from "class-variance-authority" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const alertVariants = cva( 7 | "relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7", 8 | { 9 | variants: { 10 | variant: { 11 | default: "bg-background text-foreground", 12 | destructive: 13 | "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", 14 | }, 15 | }, 16 | defaultVariants: { 17 | variant: "default", 18 | }, 19 | } 20 | ) 21 | 22 | const Alert = React.forwardRef< 23 | HTMLDivElement, 24 | React.HTMLAttributes & VariantProps 25 | >(({ className, variant, ...props }, ref) => ( 26 |
32 | )) 33 | Alert.displayName = "Alert" 34 | 35 | const AlertTitle = React.forwardRef< 36 | HTMLParagraphElement, 37 | React.HTMLAttributes 38 | >(({ className, ...props }, ref) => ( 39 |
44 | )) 45 | AlertTitle.displayName = "AlertTitle" 46 | 47 | const AlertDescription = React.forwardRef< 48 | HTMLParagraphElement, 49 | React.HTMLAttributes 50 | >(({ className, ...props }, ref) => ( 51 |
56 | )) 57 | AlertDescription.displayName = "AlertDescription" 58 | 59 | export { Alert, AlertTitle, AlertDescription } 60 | -------------------------------------------------------------------------------- /src/components/ui/aspect-ratio.tsx: -------------------------------------------------------------------------------- 1 | import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio" 2 | 3 | const AspectRatio = AspectRatioPrimitive.Root 4 | 5 | export { AspectRatio } 6 | -------------------------------------------------------------------------------- /src/components/ui/avatar.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as AvatarPrimitive from "@radix-ui/react-avatar" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const Avatar = React.forwardRef< 7 | React.ElementRef, 8 | React.ComponentPropsWithoutRef 9 | >(({ className, ...props }, ref) => ( 10 | 18 | )) 19 | Avatar.displayName = AvatarPrimitive.Root.displayName 20 | 21 | const AvatarImage = React.forwardRef< 22 | React.ElementRef, 23 | React.ComponentPropsWithoutRef 24 | >(({ className, ...props }, ref) => ( 25 | 30 | )) 31 | AvatarImage.displayName = AvatarPrimitive.Image.displayName 32 | 33 | const AvatarFallback = React.forwardRef< 34 | React.ElementRef, 35 | React.ComponentPropsWithoutRef 36 | >(({ className, ...props }, ref) => ( 37 | 45 | )) 46 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName 47 | 48 | export { Avatar, AvatarImage, AvatarFallback } 49 | -------------------------------------------------------------------------------- /src/components/ui/badge.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { cva, type VariantProps } from "class-variance-authority" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const badgeVariants = cva( 7 | "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", 8 | { 9 | variants: { 10 | variant: { 11 | default: 12 | "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80", 13 | secondary: 14 | "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", 15 | destructive: 16 | "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80", 17 | outline: "text-foreground", 18 | }, 19 | }, 20 | defaultVariants: { 21 | variant: "default", 22 | }, 23 | } 24 | ) 25 | 26 | export interface BadgeProps 27 | extends React.HTMLAttributes, 28 | VariantProps {} 29 | 30 | function Badge({ className, variant, ...props }: BadgeProps) { 31 | return ( 32 |
33 | ) 34 | } 35 | 36 | export { Badge, badgeVariants } 37 | -------------------------------------------------------------------------------- /src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import { Slot } from "@radix-ui/react-slot" 2 | import { cva, type VariantProps } from "class-variance-authority" 3 | import * as React from "react" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center whitespace-nowrap font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "text-primary-foreground shadow hover:bg-palette-green/90", 14 | destructive: 15 | "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", 16 | outline: 17 | "border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground", 18 | secondary: 19 | "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", 20 | ghost: "hover:bg-accent hover:text-accent-foreground", 21 | link: "text-primary underline-offset-4 hover:underline", 22 | }, 23 | size: { 24 | default: "h-9 px-4 py-2", 25 | sm: "h-8 rounded-md px-3 text-xs", 26 | lg: "h-10 rounded-md px-8", 27 | icon: "h-9 w-9", 28 | }, 29 | }, 30 | defaultVariants: { 31 | variant: "default", 32 | size: "default", 33 | }, 34 | } 35 | ) 36 | 37 | export interface ButtonProps 38 | extends React.ButtonHTMLAttributes, 39 | VariantProps { 40 | asChild?: boolean 41 | } 42 | 43 | const Button = React.forwardRef( 44 | ({ className, variant, size, asChild = false, ...props }, ref) => { 45 | const Comp = asChild ? Slot : "button" 46 | return ( 47 | 52 | ) 53 | } 54 | ) 55 | Button.displayName = "Button" 56 | 57 | export { Button, buttonVariants } 58 | -------------------------------------------------------------------------------- /src/components/ui/calendar.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { ChevronLeftIcon, ChevronRightIcon } from "@radix-ui/react-icons"; 3 | import { DayPicker } from "react-day-picker"; 4 | 5 | import { cn } from "@/lib/utils"; 6 | import { buttonVariants } from "@/components/ui/button"; 7 | 8 | export type CalendarProps = React.ComponentProps; 9 | 10 | function Calendar({ 11 | className, 12 | classNames, 13 | showOutsideDays = true, 14 | ...props 15 | }: CalendarProps) { 16 | return ( 17 | .day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md" 41 | : "[&:has([aria-selected])]:rounded-md" 42 | ), 43 | day: cn( 44 | buttonVariants({ variant: "ghost" }), 45 | "h-8 w-8 p-0 font-normal aria-selected:opacity-100" 46 | ), 47 | day_range_start: "day-range-start", 48 | day_range_end: "day-range-end", 49 | day_selected: 50 | "bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground", 51 | day_today: "bg-accent text-accent-foreground", 52 | day_outside: "text-muted-foreground opacity-50", 53 | day_disabled: "text-muted-foreground opacity-50", 54 | day_range_middle: 55 | "aria-selected:bg-accent aria-selected:text-accent-foreground", 56 | day_hidden: "invisible", 57 | ...classNames, 58 | }} 59 | components={{ 60 | IconLeft: () => , 61 | IconRight: () => , 62 | }} 63 | {...props} 64 | /> 65 | ); 66 | } 67 | Calendar.displayName = "Calendar"; 68 | 69 | export { Calendar }; 70 | -------------------------------------------------------------------------------- /src/components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | const Card = React.forwardRef< 6 | HTMLDivElement, 7 | React.HTMLAttributes 8 | >(({ className, ...props }, ref) => ( 9 |
17 | )) 18 | Card.displayName = "Card" 19 | 20 | const CardHeader = React.forwardRef< 21 | HTMLDivElement, 22 | React.HTMLAttributes 23 | >(({ className, ...props }, ref) => ( 24 |
29 | )) 30 | CardHeader.displayName = "CardHeader" 31 | 32 | const CardTitle = React.forwardRef< 33 | HTMLParagraphElement, 34 | React.HTMLAttributes 35 | >(({ className, ...props }, ref) => ( 36 |

41 | )) 42 | CardTitle.displayName = "CardTitle" 43 | 44 | const CardDescription = React.forwardRef< 45 | HTMLParagraphElement, 46 | React.HTMLAttributes 47 | >(({ className, ...props }, ref) => ( 48 |

53 | )) 54 | CardDescription.displayName = "CardDescription" 55 | 56 | const CardContent = React.forwardRef< 57 | HTMLDivElement, 58 | React.HTMLAttributes 59 | >(({ className, ...props }, ref) => ( 60 |

61 | )) 62 | CardContent.displayName = "CardContent" 63 | 64 | const CardFooter = React.forwardRef< 65 | HTMLDivElement, 66 | React.HTMLAttributes 67 | >(({ className, ...props }, ref) => ( 68 |
73 | )) 74 | CardFooter.displayName = "CardFooter" 75 | 76 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } 77 | -------------------------------------------------------------------------------- /src/components/ui/checkbox.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as CheckboxPrimitive from "@radix-ui/react-checkbox" 3 | import { CheckIcon } from "@radix-ui/react-icons" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const Checkbox = React.forwardRef< 8 | React.ElementRef, 9 | React.ComponentPropsWithoutRef 10 | >(({ className, ...props }, ref) => ( 11 | 19 | 22 | 23 | 24 | 25 | )) 26 | Checkbox.displayName = CheckboxPrimitive.Root.displayName 27 | 28 | export { Checkbox } 29 | -------------------------------------------------------------------------------- /src/components/ui/collapsible.tsx: -------------------------------------------------------------------------------- 1 | import * as CollapsiblePrimitive from "@radix-ui/react-collapsible" 2 | 3 | const Collapsible = CollapsiblePrimitive.Root 4 | 5 | const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger 6 | 7 | const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent 8 | 9 | export { Collapsible, CollapsibleTrigger, CollapsibleContent } 10 | -------------------------------------------------------------------------------- /src/components/ui/command.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { DialogProps } from "@radix-ui/react-dialog" 3 | import { MagnifyingGlassIcon } from "@radix-ui/react-icons" 4 | import { Command as CommandPrimitive } from "cmdk" 5 | 6 | import { cn } from "@/lib/utils" 7 | import { Dialog, DialogContent } from "@/components/ui/dialog" 8 | 9 | const Command = React.forwardRef< 10 | React.ElementRef, 11 | React.ComponentPropsWithoutRef 12 | >(({ className, ...props }, ref) => ( 13 | 21 | )) 22 | Command.displayName = CommandPrimitive.displayName 23 | 24 | interface CommandDialogProps extends DialogProps {} 25 | 26 | const CommandDialog = ({ children, ...props }: CommandDialogProps) => { 27 | return ( 28 | 29 | 30 | 31 | {children} 32 | 33 | 34 | 35 | ) 36 | } 37 | 38 | const CommandInput = React.forwardRef< 39 | React.ElementRef, 40 | React.ComponentPropsWithoutRef 41 | >(({ className, ...props }, ref) => ( 42 |
43 | 44 | 52 |
53 | )) 54 | 55 | CommandInput.displayName = CommandPrimitive.Input.displayName 56 | 57 | const CommandList = React.forwardRef< 58 | React.ElementRef, 59 | React.ComponentPropsWithoutRef 60 | >(({ className, ...props }, ref) => ( 61 | 66 | )) 67 | 68 | CommandList.displayName = CommandPrimitive.List.displayName 69 | 70 | const CommandEmpty = React.forwardRef< 71 | React.ElementRef, 72 | React.ComponentPropsWithoutRef 73 | >((props, ref) => ( 74 | 79 | )) 80 | 81 | CommandEmpty.displayName = CommandPrimitive.Empty.displayName 82 | 83 | const CommandGroup = React.forwardRef< 84 | React.ElementRef, 85 | React.ComponentPropsWithoutRef 86 | >(({ className, ...props }, ref) => ( 87 | 95 | )) 96 | 97 | CommandGroup.displayName = CommandPrimitive.Group.displayName 98 | 99 | const CommandSeparator = React.forwardRef< 100 | React.ElementRef, 101 | React.ComponentPropsWithoutRef 102 | >(({ className, ...props }, ref) => ( 103 | 108 | )) 109 | CommandSeparator.displayName = CommandPrimitive.Separator.displayName 110 | 111 | const CommandItem = React.forwardRef< 112 | React.ElementRef, 113 | React.ComponentPropsWithoutRef 114 | >(({ className, ...props }, ref) => ( 115 | 123 | )) 124 | 125 | CommandItem.displayName = CommandPrimitive.Item.displayName 126 | 127 | const CommandShortcut = ({ 128 | className, 129 | ...props 130 | }: React.HTMLAttributes) => { 131 | return ( 132 | 139 | ) 140 | } 141 | CommandShortcut.displayName = "CommandShortcut" 142 | 143 | export { 144 | Command, 145 | CommandDialog, 146 | CommandInput, 147 | CommandList, 148 | CommandEmpty, 149 | CommandGroup, 150 | CommandItem, 151 | CommandShortcut, 152 | CommandSeparator, 153 | } 154 | -------------------------------------------------------------------------------- /src/components/ui/context-menu.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as ContextMenuPrimitive from "@radix-ui/react-context-menu" 3 | import { 4 | CheckIcon, 5 | ChevronRightIcon, 6 | DotFilledIcon, 7 | } from "@radix-ui/react-icons" 8 | 9 | import { cn } from "@/lib/utils" 10 | 11 | const ContextMenu = ContextMenuPrimitive.Root 12 | 13 | const ContextMenuTrigger = ContextMenuPrimitive.Trigger 14 | 15 | const ContextMenuGroup = ContextMenuPrimitive.Group 16 | 17 | const ContextMenuPortal = ContextMenuPrimitive.Portal 18 | 19 | const ContextMenuSub = ContextMenuPrimitive.Sub 20 | 21 | const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup 22 | 23 | const ContextMenuSubTrigger = React.forwardRef< 24 | React.ElementRef, 25 | React.ComponentPropsWithoutRef & { 26 | inset?: boolean 27 | } 28 | >(({ className, inset, children, ...props }, ref) => ( 29 | 38 | {children} 39 | 40 | 41 | )) 42 | ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName 43 | 44 | const ContextMenuSubContent = React.forwardRef< 45 | React.ElementRef, 46 | React.ComponentPropsWithoutRef 47 | >(({ className, ...props }, ref) => ( 48 | 56 | )) 57 | ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName 58 | 59 | const ContextMenuContent = React.forwardRef< 60 | React.ElementRef, 61 | React.ComponentPropsWithoutRef 62 | >(({ className, ...props }, ref) => ( 63 | 64 | 72 | 73 | )) 74 | ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName 75 | 76 | const ContextMenuItem = React.forwardRef< 77 | React.ElementRef, 78 | React.ComponentPropsWithoutRef & { 79 | inset?: boolean 80 | } 81 | >(({ className, inset, ...props }, ref) => ( 82 | 91 | )) 92 | ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName 93 | 94 | const ContextMenuCheckboxItem = React.forwardRef< 95 | React.ElementRef, 96 | React.ComponentPropsWithoutRef 97 | >(({ className, children, checked, ...props }, ref) => ( 98 | 107 | 108 | 109 | 110 | 111 | 112 | {children} 113 | 114 | )) 115 | ContextMenuCheckboxItem.displayName = 116 | ContextMenuPrimitive.CheckboxItem.displayName 117 | 118 | const ContextMenuRadioItem = React.forwardRef< 119 | React.ElementRef, 120 | React.ComponentPropsWithoutRef 121 | >(({ className, children, ...props }, ref) => ( 122 | 130 | 131 | 132 | 133 | 134 | 135 | {children} 136 | 137 | )) 138 | ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName 139 | 140 | const ContextMenuLabel = React.forwardRef< 141 | React.ElementRef, 142 | React.ComponentPropsWithoutRef & { 143 | inset?: boolean 144 | } 145 | >(({ className, inset, ...props }, ref) => ( 146 | 155 | )) 156 | ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName 157 | 158 | const ContextMenuSeparator = React.forwardRef< 159 | React.ElementRef, 160 | React.ComponentPropsWithoutRef 161 | >(({ className, ...props }, ref) => ( 162 | 167 | )) 168 | ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName 169 | 170 | const ContextMenuShortcut = ({ 171 | className, 172 | ...props 173 | }: React.HTMLAttributes) => { 174 | return ( 175 | 182 | ) 183 | } 184 | ContextMenuShortcut.displayName = "ContextMenuShortcut" 185 | 186 | export { 187 | ContextMenu, 188 | ContextMenuTrigger, 189 | ContextMenuContent, 190 | ContextMenuItem, 191 | ContextMenuCheckboxItem, 192 | ContextMenuRadioItem, 193 | ContextMenuLabel, 194 | ContextMenuSeparator, 195 | ContextMenuShortcut, 196 | ContextMenuGroup, 197 | ContextMenuPortal, 198 | ContextMenuSub, 199 | ContextMenuSubContent, 200 | ContextMenuSubTrigger, 201 | ContextMenuRadioGroup, 202 | } 203 | -------------------------------------------------------------------------------- /src/components/ui/dialog.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as DialogPrimitive from "@radix-ui/react-dialog" 3 | import { Cross2Icon } from "@radix-ui/react-icons" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const Dialog = DialogPrimitive.Root 8 | 9 | const DialogTrigger = DialogPrimitive.Trigger 10 | 11 | const DialogPortal = DialogPrimitive.Portal 12 | 13 | const DialogClose = DialogPrimitive.Close 14 | 15 | const DialogOverlay = React.forwardRef< 16 | React.ElementRef, 17 | React.ComponentPropsWithoutRef 18 | >(({ className, ...props }, ref) => ( 19 | 27 | )) 28 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName 29 | 30 | const DialogContent = React.forwardRef< 31 | React.ElementRef, 32 | React.ComponentPropsWithoutRef 33 | >(({ className, children, ...props }, ref) => ( 34 | 35 | 36 | 44 | {children} 45 | 46 | 47 | Close 48 | 49 | 50 | 51 | )) 52 | DialogContent.displayName = DialogPrimitive.Content.displayName 53 | 54 | const DialogHeader = ({ 55 | className, 56 | ...props 57 | }: React.HTMLAttributes) => ( 58 |
65 | ) 66 | DialogHeader.displayName = "DialogHeader" 67 | 68 | const DialogFooter = ({ 69 | className, 70 | ...props 71 | }: React.HTMLAttributes) => ( 72 |
79 | ) 80 | DialogFooter.displayName = "DialogFooter" 81 | 82 | const DialogTitle = React.forwardRef< 83 | React.ElementRef, 84 | React.ComponentPropsWithoutRef 85 | >(({ className, ...props }, ref) => ( 86 | 94 | )) 95 | DialogTitle.displayName = DialogPrimitive.Title.displayName 96 | 97 | const DialogDescription = React.forwardRef< 98 | React.ElementRef, 99 | React.ComponentPropsWithoutRef 100 | >(({ className, ...props }, ref) => ( 101 | 106 | )) 107 | DialogDescription.displayName = DialogPrimitive.Description.displayName 108 | 109 | export { 110 | Dialog, 111 | DialogPortal, 112 | DialogOverlay, 113 | DialogTrigger, 114 | DialogClose, 115 | DialogContent, 116 | DialogHeader, 117 | DialogFooter, 118 | DialogTitle, 119 | DialogDescription, 120 | } 121 | -------------------------------------------------------------------------------- /src/components/ui/dropdown-menu.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" 3 | import { 4 | CheckIcon, 5 | ChevronRightIcon, 6 | DotFilledIcon, 7 | } from "@radix-ui/react-icons" 8 | 9 | import { cn } from "@/lib/utils" 10 | 11 | const DropdownMenu = DropdownMenuPrimitive.Root 12 | 13 | const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger 14 | 15 | const DropdownMenuGroup = DropdownMenuPrimitive.Group 16 | 17 | const DropdownMenuPortal = DropdownMenuPrimitive.Portal 18 | 19 | const DropdownMenuSub = DropdownMenuPrimitive.Sub 20 | 21 | const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup 22 | 23 | const DropdownMenuSubTrigger = React.forwardRef< 24 | React.ElementRef, 25 | React.ComponentPropsWithoutRef & { 26 | inset?: boolean 27 | } 28 | >(({ className, inset, children, ...props }, ref) => ( 29 | 38 | {children} 39 | 40 | 41 | )) 42 | DropdownMenuSubTrigger.displayName = 43 | DropdownMenuPrimitive.SubTrigger.displayName 44 | 45 | const DropdownMenuSubContent = React.forwardRef< 46 | React.ElementRef, 47 | React.ComponentPropsWithoutRef 48 | >(({ className, ...props }, ref) => ( 49 | 57 | )) 58 | DropdownMenuSubContent.displayName = 59 | DropdownMenuPrimitive.SubContent.displayName 60 | 61 | const DropdownMenuContent = React.forwardRef< 62 | React.ElementRef, 63 | React.ComponentPropsWithoutRef 64 | >(({ className, sideOffset = 4, ...props }, ref) => ( 65 | 66 | 76 | 77 | )) 78 | DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName 79 | 80 | const DropdownMenuItem = React.forwardRef< 81 | React.ElementRef, 82 | React.ComponentPropsWithoutRef & { 83 | inset?: boolean 84 | } 85 | >(({ className, inset, ...props }, ref) => ( 86 | 95 | )) 96 | DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName 97 | 98 | const DropdownMenuCheckboxItem = React.forwardRef< 99 | React.ElementRef, 100 | React.ComponentPropsWithoutRef 101 | >(({ className, children, checked, ...props }, ref) => ( 102 | 111 | 112 | 113 | 114 | 115 | 116 | {children} 117 | 118 | )) 119 | DropdownMenuCheckboxItem.displayName = 120 | DropdownMenuPrimitive.CheckboxItem.displayName 121 | 122 | const DropdownMenuRadioItem = React.forwardRef< 123 | React.ElementRef, 124 | React.ComponentPropsWithoutRef 125 | >(({ className, children, ...props }, ref) => ( 126 | 134 | 135 | 136 | 137 | 138 | 139 | {children} 140 | 141 | )) 142 | DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName 143 | 144 | const DropdownMenuLabel = React.forwardRef< 145 | React.ElementRef, 146 | React.ComponentPropsWithoutRef & { 147 | inset?: boolean 148 | } 149 | >(({ className, inset, ...props }, ref) => ( 150 | 159 | )) 160 | DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName 161 | 162 | const DropdownMenuSeparator = React.forwardRef< 163 | React.ElementRef, 164 | React.ComponentPropsWithoutRef 165 | >(({ className, ...props }, ref) => ( 166 | 171 | )) 172 | DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName 173 | 174 | const DropdownMenuShortcut = ({ 175 | className, 176 | ...props 177 | }: React.HTMLAttributes) => { 178 | return ( 179 | 183 | ) 184 | } 185 | DropdownMenuShortcut.displayName = "DropdownMenuShortcut" 186 | 187 | export { 188 | DropdownMenu, 189 | DropdownMenuTrigger, 190 | DropdownMenuContent, 191 | DropdownMenuItem, 192 | DropdownMenuCheckboxItem, 193 | DropdownMenuRadioItem, 194 | DropdownMenuLabel, 195 | DropdownMenuSeparator, 196 | DropdownMenuShortcut, 197 | DropdownMenuGroup, 198 | DropdownMenuPortal, 199 | DropdownMenuSub, 200 | DropdownMenuSubContent, 201 | DropdownMenuSubTrigger, 202 | DropdownMenuRadioGroup, 203 | } 204 | -------------------------------------------------------------------------------- /src/components/ui/form.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as LabelPrimitive from "@radix-ui/react-label" 3 | import { Slot } from "@radix-ui/react-slot" 4 | import { 5 | Controller, 6 | ControllerProps, 7 | FieldPath, 8 | FieldValues, 9 | FormProvider, 10 | useFormContext, 11 | } from "react-hook-form" 12 | 13 | import { cn } from "@/lib/utils" 14 | import { Label } from "@/components/ui/label" 15 | 16 | const Form = FormProvider 17 | 18 | type FormFieldContextValue< 19 | TFieldValues extends FieldValues = FieldValues, 20 | TName extends FieldPath = FieldPath 21 | > = { 22 | name: TName 23 | } 24 | 25 | const FormFieldContext = React.createContext( 26 | {} as FormFieldContextValue 27 | ) 28 | 29 | const FormField = < 30 | TFieldValues extends FieldValues = FieldValues, 31 | TName extends FieldPath = FieldPath 32 | >({ 33 | ...props 34 | }: ControllerProps) => { 35 | return ( 36 | 37 | 38 | 39 | ) 40 | } 41 | 42 | const useFormField = () => { 43 | const fieldContext = React.useContext(FormFieldContext) 44 | const itemContext = React.useContext(FormItemContext) 45 | const { getFieldState, formState } = useFormContext() 46 | 47 | const fieldState = getFieldState(fieldContext.name, formState) 48 | 49 | if (!fieldContext) { 50 | throw new Error("useFormField should be used within ") 51 | } 52 | 53 | const { id } = itemContext 54 | 55 | return { 56 | id, 57 | name: fieldContext.name, 58 | formItemId: `${id}-form-item`, 59 | formDescriptionId: `${id}-form-item-description`, 60 | formMessageId: `${id}-form-item-message`, 61 | ...fieldState, 62 | } 63 | } 64 | 65 | type FormItemContextValue = { 66 | id: string 67 | } 68 | 69 | const FormItemContext = React.createContext( 70 | {} as FormItemContextValue 71 | ) 72 | 73 | const FormItem = React.forwardRef< 74 | HTMLDivElement, 75 | React.HTMLAttributes 76 | >(({ className, ...props }, ref) => { 77 | const id = React.useId() 78 | 79 | return ( 80 | 81 |
82 | 83 | ) 84 | }) 85 | FormItem.displayName = "FormItem" 86 | 87 | const FormLabel = React.forwardRef< 88 | React.ElementRef, 89 | React.ComponentPropsWithoutRef 90 | >(({ className, ...props }, ref) => { 91 | const { error, formItemId } = useFormField() 92 | 93 | return ( 94 |