├── .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 ├── 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 │ ├── 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 │ │ ├── SoloTriviaGame.tsx │ │ ├── TriviaGame.tsx │ │ └── TriviaResult.tsx ├── types │ └── index.ts └── utils │ └── countdown.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 triviaGames from "../triviaGames.js"; 18 | import type * as triviaQuestions from "../triviaQuestions.js"; 19 | import type * as types from "../types.js"; 20 | import type * as users from "../users.js"; 21 | 22 | /** 23 | * A utility for referencing Convex functions in your app's API. 24 | * 25 | * Usage: 26 | * ```js 27 | * const myFunctionReference = api.myModule.myFunction; 28 | * ``` 29 | */ 30 | declare const fullApi: ApiFromModules<{ 31 | triviaGames: typeof triviaGames; 32 | triviaQuestions: typeof triviaQuestions; 33 | types: typeof types; 34 | users: typeof users; 35 | }>; 36 | export declare const api: FilterApi< 37 | typeof fullApi, 38 | FunctionReference 39 | >; 40 | export declare const internal: FilterApi< 41 | typeof fullApi, 42 | FunctionReference 43 | >; 44 | -------------------------------------------------------------------------------- /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: process.env.CLERK_JWT_ISSUER_DOMAIN, 5 | applicationID: "convex", 6 | }, 7 | ], 8 | }; 9 | -------------------------------------------------------------------------------- /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.optional(v.id("users")), // Optional for solo games 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.optional(v.id("users")), // Optional for anonymous players 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 | 4 | 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 | // Get existing questions from the database (randomly select 10) 25 | const allQuestions = await ctx.db.query("triviaQuestions").collect(); 26 | 27 | if (allQuestions.length === 0) { 28 | throw new Error("No questions available in database"); 29 | } 30 | 31 | // Shuffle and take 10 questions 32 | const shuffledQuestions = allQuestions.sort(() => Math.random() - 0.5); 33 | const selectedQuestions = shuffledQuestions.slice(0, 10); 34 | const questionIds = selectedQuestions.map(q => q._id); 35 | 36 | const gameId = await ctx.db.insert("triviaGames", { 37 | status: "waiting", 38 | hostUserId: userData._id, 39 | startDateTime: undefined, 40 | endDateTime: undefined, 41 | currentQuestionIndex: 0, 42 | triviaQuestionIds: questionIds, 43 | weekNumber: Math.floor(Date.now() / (7 * 24 * 60 * 60 * 1000)), 44 | questionStartedAt: 0, 45 | isInReviewPhase: false, 46 | }); 47 | 48 | await ctx.db.insert("triviaParticipants", { 49 | userId: userData._id, 50 | gameId: gameId, 51 | name: userData.name, 52 | score: 0, 53 | answers: [], 54 | }); 55 | 56 | return gameId; 57 | }, 58 | }); 59 | 60 | export const getAvailableGame = query({ 61 | args: {}, 62 | handler: async (ctx) => { 63 | const availableGame = await ctx.db 64 | .query("triviaGames") 65 | .filter(q => q.eq(q.field("status"), "waiting")) 66 | .first(); 67 | 68 | return availableGame; 69 | }, 70 | }); 71 | 72 | export const joinTriviaGame = mutation({ 73 | args: { gameId: v.id("triviaGames") }, 74 | handler: async (ctx, args) => { 75 | const identity = await ctx.auth.getUserIdentity(); 76 | if (!identity) { 77 | throw new Error("Not authenticated"); 78 | } 79 | 80 | const user = await ctx.db 81 | .query("users") 82 | .withIndex("by_token", (q) => q.eq("tokenIdentifier", identity.tokenIdentifier)) 83 | .unique(); 84 | 85 | if (!user) { 86 | throw new Error("User not found"); 87 | } 88 | 89 | const game = await ctx.db.get(args.gameId); 90 | if (!game || game.status !== "waiting") { 91 | throw new Error("Game not available for joining"); 92 | } 93 | 94 | // Check if the user has already joined 95 | const existingParticipant = await ctx.db 96 | .query("triviaParticipants") 97 | .withIndex("by_user_and_game", (q) => 98 | q.eq("userId", user._id).eq("gameId", args.gameId) 99 | ) 100 | .unique(); 101 | 102 | if (existingParticipant) { 103 | return { success: true }; 104 | } 105 | 106 | await ctx.db.insert("triviaParticipants", { 107 | userId: user._id, 108 | gameId: args.gameId, 109 | name: user.name, 110 | score: 0, 111 | answers: [], 112 | }); 113 | 114 | return { success: true }; 115 | }, 116 | }); 117 | 118 | export const getTriviaGame = query({ 119 | args: { gameId: v.id("triviaGames") }, 120 | handler: async (ctx, args) => { 121 | const game = await ctx.db.get(args.gameId); 122 | if (!game) { 123 | return null; 124 | } 125 | 126 | const questions = await Promise.all( 127 | game.triviaQuestionIds.map(async (id) => { 128 | const question = await ctx.db.get(id); 129 | return question; 130 | }) 131 | ); 132 | 133 | const participants = await ctx.db 134 | .query("triviaParticipants") 135 | .withIndex("by_game", (q) => q.eq("gameId", args.gameId)) 136 | .collect(); 137 | 138 | return { game, questions, participants }; 139 | }, 140 | }); 141 | 142 | export const startTriviaGame = mutation({ 143 | args: { gameId: v.id("triviaGames") }, 144 | handler: async (ctx, args) => { 145 | 146 | const identity = await ctx.auth.getUserIdentity(); 147 | if (!identity) { 148 | throw new Error("Not authenticated"); 149 | } 150 | 151 | const user = await ctx.db 152 | .query("users") 153 | .withIndex("by_token", (q) => q.eq("tokenIdentifier", identity.tokenIdentifier)) 154 | .unique(); 155 | 156 | if (!user) { 157 | throw new Error("User not found"); 158 | } 159 | 160 | const game = await ctx.db.get(args.gameId); 161 | if (!game) { 162 | throw new Error("Game not found"); 163 | } 164 | 165 | if (game.hostUserId !== user._id) { 166 | throw new Error("Not authorized to start the game"); 167 | } 168 | 169 | if (game.status !== "waiting") { 170 | throw new Error(`Cannot start game with status: ${game.status}`); 171 | } 172 | 173 | const startDateTime = new Date(); 174 | const endDateTime = new Date(startDateTime.getTime() + game.triviaQuestionIds.length * (20 + 3) * 1000); // amount of questions, 20 seconds each, 3 second review phase 175 | 176 | await ctx.db.patch(args.gameId, { 177 | status: "in_progress", 178 | startDateTime: startDateTime.toISOString(), 179 | endDateTime: endDateTime.toISOString(), 180 | questionStartedAt: Date.now(), 181 | isInReviewPhase: false, 182 | }); 183 | return { success: true, gameId: args.gameId }; 184 | }, 185 | }); 186 | 187 | export const moveToReviewPhase = mutation({ 188 | args: { gameId: v.id("triviaGames") }, 189 | handler: async (ctx, args) => { 190 | const identity = await ctx.auth.getUserIdentity(); 191 | if (!identity) { 192 | throw new Error("Not authenticated"); 193 | } 194 | 195 | const user = await ctx.db 196 | .query("users") 197 | .withIndex("by_token", q => q.eq("tokenIdentifier", identity.tokenIdentifier)) 198 | .unique(); 199 | 200 | const game = await ctx.db.get(args.gameId); 201 | if (!game || game.status !== "in_progress") { 202 | throw new Error("Game is not in progress"); 203 | } 204 | 205 | if (!user || user._id !== game.hostUserId) { 206 | throw new Error("Only host can move to next question"); 207 | } 208 | 209 | await ctx.db.patch(args.gameId, { 210 | isInReviewPhase: true, 211 | questionStartedAt: Date.now(), 212 | }) 213 | 214 | return { success: true }; 215 | } 216 | }) 217 | 218 | export const submitAnswer = mutation({ 219 | args: { 220 | gameId: v.id("triviaGames"), 221 | questionId: v.id("triviaQuestions"), 222 | answer: v.string(), 223 | timeRemaining: v.number(), 224 | }, 225 | handler: async (ctx, args) => { 226 | const identity = await ctx.auth.getUserIdentity(); 227 | if (!identity) { 228 | throw new Error("Not authenticated"); 229 | } 230 | 231 | const user = await ctx.db 232 | .query("users") 233 | .withIndex("by_token", (q) => q.eq("tokenIdentifier", identity.tokenIdentifier)) 234 | .unique(); 235 | 236 | if (!user) { 237 | throw new Error("User not found"); 238 | } 239 | 240 | const game = await ctx.db.get(args.gameId); 241 | if (!game || game.status !== "in_progress") { 242 | throw new Error("Game is not in progress"); 243 | } 244 | 245 | const question = await ctx.db.get(args.questionId); 246 | if (!question) { 247 | throw new Error("Question not found"); 248 | } 249 | 250 | let participant = await ctx.db 251 | .query("triviaParticipants") 252 | .withIndex("by_user_and_game", q => 253 | q.eq("userId", user._id) 254 | .eq("gameId", args.gameId) 255 | ) 256 | .unique(); 257 | 258 | if (!participant) { 259 | // If participant is not found, create a new one 260 | const participantId = await ctx.db.insert("triviaParticipants", { 261 | userId: user._id, 262 | gameId: args.gameId, 263 | name: user.name, 264 | score: 0, 265 | answers: [], 266 | }); 267 | participant = await ctx.db.get(participantId); 268 | } 269 | 270 | if (!participant) { 271 | throw new Error("Failed to create or find participant"); 272 | } 273 | 274 | // Check if the answer for this question has already been submitted 275 | const existingAnswer = participant.answers.find(a => a.questionId === args.questionId); 276 | if (existingAnswer) { 277 | return { success: true, pointsEarned: existingAnswer.pointsEarned, message: "Answer already submitted" }; 278 | } 279 | 280 | const isCorrect = question.correctChoice === args.answer; 281 | const pointsEarned = isCorrect ? Math.round(args.timeRemaining) : 0; 282 | 283 | await ctx.db.patch(participant._id, { 284 | score: (participant.score || 0) + pointsEarned, 285 | answers: [ 286 | ...(participant.answers || []), 287 | { 288 | questionId: args.questionId, 289 | answerSubmitted: args.answer, 290 | timeRemaining: args.timeRemaining, 291 | pointsEarned, 292 | }, 293 | ], 294 | }); 295 | 296 | return { success: true, pointsEarned }; 297 | }, 298 | }); 299 | 300 | export const moveToNextQuestion = mutation({ 301 | args: { gameId: v.id("triviaGames") }, 302 | handler: async (ctx, args) => { 303 | 304 | const identity = await ctx.auth.getUserIdentity(); 305 | if (!identity) throw new Error("Not authenticated"); 306 | 307 | const user = await ctx.db 308 | .query("users") 309 | .withIndex("by_token", q => q.eq("tokenIdentifier", identity.tokenIdentifier)) 310 | .unique(); 311 | 312 | const game = await ctx.db.get(args.gameId); 313 | if (!game || game.status !== "in_progress") { 314 | throw new Error("Game is not in progress"); 315 | } 316 | 317 | if (!user || user._id !== game.hostUserId) { 318 | throw new Error("Only host can move to next question"); 319 | } 320 | 321 | const nextQuestionIndex = game.currentQuestionIndex + 1; 322 | 323 | if (nextQuestionIndex >= game.triviaQuestionIds.length) { 324 | await ctx.db.patch(args.gameId, { 325 | status: "finished", 326 | currentQuestionIndex: nextQuestionIndex - 1 327 | }); 328 | return { gameFinished: true, newQuestionIndex: nextQuestionIndex - 1 }; 329 | } 330 | 331 | await ctx.db.patch(args.gameId, { 332 | currentQuestionIndex: nextQuestionIndex, 333 | questionStartedAt: Date.now(), 334 | isInReviewPhase: false, 335 | }); 336 | 337 | return { success: true, newQuestionIndex: nextQuestionIndex }; 338 | }, 339 | }); 340 | 341 | export const getLeaderboard = query({ 342 | args: { gameId: v.id("triviaGames") }, 343 | handler: async (ctx, args) => { 344 | const participants = await ctx.db 345 | .query("triviaParticipants") 346 | .withIndex("by_game", q => q.eq("gameId", args.gameId)) 347 | .collect(); 348 | 349 | const leaderboard = await Promise.all( 350 | participants.map(async participant => { 351 | const user = participant.userId ? await ctx.db.get(participant.userId) : null; 352 | return { 353 | name: user?.name || participant.name, 354 | score: participant.score, 355 | }; 356 | }) 357 | ); 358 | 359 | return leaderboard.sort((a, b) => b.score - a.score); 360 | }, 361 | }); 362 | 363 | export const getMostRecentGameLeaderboard = query({ 364 | args: {}, 365 | handler: async (ctx) => { 366 | // Get all finished games and their participants for a global leaderboard 367 | const allParticipants = await ctx.db 368 | .query("triviaParticipants") 369 | .collect(); 370 | 371 | // Filter participants from finished games only 372 | const finishedGameParticipants = []; 373 | for (const participant of allParticipants) { 374 | const game = await ctx.db.get(participant.gameId); 375 | if (game && game.status === "finished") { 376 | finishedGameParticipants.push(participant); 377 | } 378 | } 379 | 380 | if (finishedGameParticipants.length === 0) { 381 | return null; 382 | } 383 | 384 | const leaderboard = await Promise.all( 385 | finishedGameParticipants.map(async (participant) => { 386 | const user = participant.userId ? await ctx.db.get(participant.userId) : null; 387 | return { 388 | name: user?.name || participant.name, 389 | score: participant.score, 390 | gameId: participant.gameId, 391 | }; 392 | }) 393 | ); 394 | 395 | return leaderboard.sort((a, b) => b.score - a.score); 396 | }, 397 | }); 398 | 399 | // Generate random anonymous player names 400 | function generateAnonymousName(): string { 401 | const adjectives = [ 402 | "Swift", "Clever", "Ninja", "Cyber", "Digital", "Code", "Binary", "Quantum", 403 | "Pixel", "Logic", "Stealth", "Turbo", "Elite", "Prime", "Alpha", "Beta", 404 | "Gamma", "Delta", "Omega", "Neon", "Chrome", "Shadow", "Ghost", "Phantom", 405 | "Mystic", "Cosmic", "Atomic", "Electric", "Magnetic", "Sonic", "Hyper", 406 | "Ultra", "Mega", "Super", "Blazing", "Lightning", "Thunder", "Storm" 407 | ]; 408 | 409 | const nouns = [ 410 | "Coder", "Hacker", "Developer", "Programmer", "Engineer", "Architect", "Wizard", 411 | "Ninja", "Warrior", "Guardian", "Hunter", "Ranger", "Scout", "Agent", "Operative", 412 | "Pilot", "Captain", "Commander", "Chief", "Master", "Expert", "Guru", "Sage", 413 | "Phoenix", "Dragon", "Tiger", "Wolf", "Eagle", "Falcon", "Hawk", "Raven", 414 | "Viper", "Cobra", "Panther", "Lynx", "Fox", "Bear", "Lion", "Shark" 415 | ]; 416 | 417 | const randomAdjective = adjectives[Math.floor(Math.random() * adjectives.length)]; 418 | const randomNoun = nouns[Math.floor(Math.random() * nouns.length)]; 419 | const randomNumber = Math.floor(Math.random() * 999) + 1; 420 | 421 | return `${randomAdjective}${randomNoun}${randomNumber}`; 422 | } 423 | 424 | // Solo game functions for on-demand play 425 | export const createSoloGame = mutation({ 426 | args: {}, 427 | handler: async (ctx) => { 428 | // Get existing questions from the database (randomly select 10) 429 | const allQuestions = await ctx.db.query("triviaQuestions").collect(); 430 | 431 | if (allQuestions.length === 0) { 432 | throw new Error("No questions available in database"); 433 | } 434 | 435 | // Shuffle and take 10 questions 436 | const shuffledQuestions = allQuestions.sort(() => Math.random() - 0.5); 437 | const selectedQuestions = shuffledQuestions.slice(0, 1); 438 | const questionIds = selectedQuestions.map(q => q._id); 439 | 440 | // Create the game without requiring authentication 441 | const gameId = await ctx.db.insert("triviaGames", { 442 | status: "in_progress", // Start immediately 443 | hostUserId: undefined, // No host for solo games 444 | startDateTime: new Date().toISOString(), 445 | endDateTime: undefined, 446 | currentQuestionIndex: 0, 447 | triviaQuestionIds: questionIds, 448 | weekNumber: Math.floor(Date.now() / (7 * 24 * 60 * 60 * 1000)), 449 | questionStartedAt: Date.now(), 450 | isInReviewPhase: false, 451 | }); 452 | 453 | // Create anonymous participant with random name 454 | await ctx.db.insert("triviaParticipants", { 455 | userId: undefined, // Anonymous user 456 | gameId: gameId, 457 | name: generateAnonymousName(), 458 | score: 0, 459 | answers: [], 460 | }); 461 | 462 | return gameId; 463 | }, 464 | }); 465 | 466 | export const submitSoloAnswer = mutation({ 467 | args: { 468 | gameId: v.id("triviaGames"), 469 | questionId: v.id("triviaQuestions"), 470 | answer: v.string(), 471 | timeRemaining: v.number(), 472 | }, 473 | handler: async (ctx, args) => { 474 | const game = await ctx.db.get(args.gameId); 475 | if (!game || game.status !== "in_progress") { 476 | throw new Error("Game is not in progress"); 477 | } 478 | 479 | const question = await ctx.db.get(args.questionId); 480 | if (!question) { 481 | throw new Error("Question not found"); 482 | } 483 | 484 | // Find the anonymous participant for this game 485 | const participant = await ctx.db 486 | .query("triviaParticipants") 487 | .withIndex("by_game", q => q.eq("gameId", args.gameId)) 488 | .first(); 489 | 490 | if (!participant) { 491 | throw new Error("Participant not found"); 492 | } 493 | 494 | // Check if the answer for this question has already been submitted 495 | const existingAnswer = participant.answers.find(a => a.questionId === args.questionId); 496 | if (existingAnswer) { 497 | return { success: true, pointsEarned: existingAnswer.pointsEarned, message: "Answer already submitted" }; 498 | } 499 | 500 | const isCorrect = question.correctChoice === args.answer; 501 | const pointsEarned = isCorrect ? Math.round(args.timeRemaining) : 0; 502 | 503 | await ctx.db.patch(participant._id, { 504 | score: (participant.score || 0) + pointsEarned, 505 | answers: [ 506 | ...(participant.answers || []), 507 | { 508 | questionId: args.questionId, 509 | answerSubmitted: args.answer, 510 | timeRemaining: args.timeRemaining, 511 | pointsEarned, 512 | }, 513 | ], 514 | }); 515 | 516 | return { success: true, pointsEarned }; 517 | }, 518 | }); 519 | 520 | export const moveToNextSoloQuestion = mutation({ 521 | args: { 522 | gameId: v.id("triviaGames"), 523 | showReview: v.boolean(), 524 | }, 525 | handler: async (ctx, args) => { 526 | const game = await ctx.db.get(args.gameId); 527 | if (!game || game.status !== "in_progress") { 528 | throw new Error("Game is not in progress"); 529 | } 530 | 531 | if (args.showReview && !game.isInReviewPhase) { 532 | // Move to review phase 533 | await ctx.db.patch(args.gameId, { 534 | isInReviewPhase: true, 535 | questionStartedAt: Date.now(), 536 | }); 537 | return { success: true, reviewPhase: true }; 538 | } 539 | 540 | // Move to next question or finish game 541 | const nextQuestionIndex = game.currentQuestionIndex + 1; 542 | 543 | if (nextQuestionIndex >= game.triviaQuestionIds.length) { 544 | await ctx.db.patch(args.gameId, { 545 | status: "finished", 546 | currentQuestionIndex: nextQuestionIndex - 1 547 | }); 548 | return { gameFinished: true, newQuestionIndex: nextQuestionIndex - 1 }; 549 | } 550 | 551 | await ctx.db.patch(args.gameId, { 552 | currentQuestionIndex: nextQuestionIndex, 553 | questionStartedAt: Date.now(), 554 | isInReviewPhase: false, 555 | }); 556 | 557 | return { success: true, newQuestionIndex: nextQuestionIndex }; 558 | }, 559 | }); 560 | 561 | 562 | 563 | export const getCurrentGame = query({ 564 | args: {}, 565 | handler: async (ctx) => { 566 | const identity = await ctx.auth.getUserIdentity(); 567 | if (!identity) return null; 568 | 569 | const user = await ctx.db 570 | .query('users') 571 | .withIndex('by_token', (q) => q.eq('tokenIdentifier', identity.tokenIdentifier)) 572 | .first(); 573 | 574 | if (!user) return null; 575 | 576 | const participations = await ctx.db 577 | .query('triviaParticipants') 578 | .withIndex('by_user', (q) => q.eq('userId', user._id)) 579 | .collect(); 580 | 581 | for (const participation of participations) { 582 | const game = await ctx.db.get(participation.gameId); 583 | if (game && (game.status === 'waiting' || game.status === 'in_progress')) { 584 | return game._id; 585 | } 586 | } 587 | return null; 588 | }, 589 | }); -------------------------------------------------------------------------------- /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 | } 12 | -------------------------------------------------------------------------------- /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 { Link } from "react-router-dom"; 4 | import { api } from "../convex/_generated/api"; 5 | import { Button } from "./components/ui/button"; 6 | 7 | export default function App() { 8 | const recentLeaderboard = useQuery(api.triviaGames.getMostRecentGameLeaderboard); 9 | 10 | return ( 11 |
12 |
13 |

14 | Dev Trivia 15 |

16 |

17 | Test your coding knowledge with our on-demand trivia game! Jump in anytime for 10 rounds of brain-teasing questions covering everything from programming languages to computer science fundamentals. Answer quickly to earn more points - the faster you respond correctly, the higher your score! 18 |

19 |
20 | 21 |
22 | 23 | 26 | 27 |
28 | 29 |
30 |

31 | --- How It Works --- 32 |

33 |

34 | • 10 multiple-choice questions about programming and computer science
35 | • 20 seconds per question - answer quickly for more points
36 | • No login required to play, but sign in to save your score to the leaderboard
37 | • Play as many times as you want, whenever you want! 38 |

39 |
40 | Dev Trivia used to be a weekly, live trivia game where all players answered the same questions simultaneously every Wednesday at 12pm ET. Thanks for your patience as we transitioned to this new on-demand format! - Forrest 41 |
42 |
43 |
44 |
45 |

--- Recent High Scores ---

46 | {recentLeaderboard ? ( 47 | recentLeaderboard.slice(0, 10).map((participant, index) => ( 48 |
49 |
{index + 1})
50 |
51 |
52 | {participant.name} 53 |
54 |
55 |
56 | )) 57 | ) : ( 58 |

No games played yet. Be the first to play!

59 | )} 60 |
61 |
62 |
63 | ); 64 | } 65 | -------------------------------------------------------------------------------- /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/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 } from 'react-router-dom'; 2 | import Header from "../Header"; 3 | 4 | export default function MainLayout() { 5 | return ( 6 |
7 |
8 |
12 |
13 |
14 | 15 |
16 |
17 |
18 |
19 | ); 20 | } -------------------------------------------------------------------------------- /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 |