diff --git a/.env.example b/.env.example index 9e9daec..a439a5f 100644 --- a/.env.example +++ b/.env.example @@ -4,21 +4,17 @@ # This file will be committed to version control, so make sure not to have any secrets in it. # If you are cloning this repo, create a copy of this file named `.env` and populate it with your secrets. -# We use dotenv to load Prisma from Next.js' .env file -# @see https://www.prisma.io/docs/reference/database-reference/connection-urls -DATABASE_URL=file:./db.sqlite - -# @see https://next-auth.js.org/configuration/options#nextauth_url -NEXTAUTH_URL=http://localhost:3000 +# The database URL is used to connect to your PlanetScale database. +DB_HOST='aws.connect.psdb.cloud' +DB_NAME='YOUR_DB_NAME' +DB_USERNAME='' +DB_PASSWORD='pscale_pw_' # You can generate the secret via 'openssl rand -base64 32' on Unix # @see https://next-auth.js.org/configuration/options#secret -NEXTAUTH_SECRET=supersecret +AUTH_SECRET='supersecret' # Preconfigured Discord OAuth provider, works out-of-the-box # @see https://next-auth.js.org/providers/discord -DISCORD_CLIENT_ID= -DISCORD_CLIENT_SECRET= - -PLANETSCALE_SERVELESS_USERNAME= -PLANETSCALE_SERVELESS_PASSWORD= \ No newline at end of file +AUTH_DISCORD_ID='' +AUTH_DISCORD_SECRET='' diff --git a/.eslintrc.js b/.eslintrc.js deleted file mode 100644 index fb7669f..0000000 --- a/.eslintrc.js +++ /dev/null @@ -1,50 +0,0 @@ -/** @type {import("eslint").Linter.Config} */ -const config = { - extends: ["prettier", "eslint:recommended"], - parserOptions: { - ecmaVersion: "latest", - }, - env: { - es6: true, - }, - overrides: [ - { - extends: [ - "plugin:@typescript-eslint/recommended", - "plugin:@typescript-eslint/recommended-requiring-type-checking", - ], - files: ["**/*.ts", "**/*.tsx"], - parserOptions: { - ecmaVersion: "latest", - tsconfigRootDir: __dirname, - project: [ - "./tsconfig.json", - "./apps/*/tsconfig.json", - "./packages/*/tsconfig.json", - ], - }, - rules: { - "@typescript-eslint/no-unused-vars": [ - "error", - { - argsIgnorePattern: "^_", - varsIgnorePattern: "^_", - caughtErrorsIgnorePattern: "^_", - }, - ], - "@typescript-eslint/require-await": "off", - "@typescript-eslint/no-non-null-assertion": "off", - }, - }, - ], - root: true, - reportUnusedDisableDirectives: true, - ignorePatterns: [ - ".eslintrc.js", - "**/*.config.js", - "**/*.config.cjs", - "packages/config/**", - ], -}; - -module.exports = config; diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fd7522d..8500662 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,57 +5,53 @@ on: branches: ["*"] push: branches: ["main"] + merge_group: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} # You can leverage Vercel Remote Caching with Turbo to speed up your builds # @link https://turborepo.org/docs/core-concepts/remote-caching#remote-caching-on-vercel-builds env: + FORCE_COLOR: 3 + TURBO_TEAM: ${{ vars.TURBO_TEAM }} TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} - TURBO_TEAM: ${{ secrets.TURBO_TEAM }} jobs: - build-lint: - env: - DATABASE_URL: file:./db.sqlite + lint: runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup + uses: ./tooling/github/setup + + - name: Copy env + shell: bash + run: cp .env.example .env + - name: Lint + run: pnpm lint && pnpm lint:ws + + format: + runs-on: ubuntu-latest steps: - - name: Checkout repo - uses: actions/checkout@v3 - - - name: Setup pnpm - uses: pnpm/action-setup@v2.2.4 - - - name: Setup Node 18 - uses: actions/setup-node@v3 - with: - node-version: 18 - - - name: Get pnpm store directory - id: pnpm-cache - run: | - echo "pnpm_cache_dir=$(pnpm store path)" >> $GITHUB_OUTPUT - - - name: Setup pnpm cache - uses: actions/cache@v3 - with: - path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }} - key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-pnpm-store- - - - name: Install deps (with cache) - run: pnpm install - - # Normally, this would be done as part of the turbo pipeline - however since the Expo app doesn't depend on `@acme/db` it doesn't care. - # TODO: Free for all to find a better solution here. - - name: Generate Prisma Client - run: pnpm turbo db:generate - - - name: Build, lint and type-check - run: pnpm turbo build lint type-check - env: - SKIP_ENV_VALIDATION: true - - # FIXME: Add this back once we have an Expo SDK supporting React 18.2 - # - name: Check workspaces - # run: pnpm manypkg check + - uses: actions/checkout@v4 + + - name: Setup + uses: ./tooling/github/setup + + - name: Format + run: pnpm format + + typecheck: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup + uses: ./tooling/github/setup + + - name: Typecheck + run: pnpm typecheck diff --git a/.gitignore b/.gitignore index 1404a13..d182b91 100644 --- a/.gitignore +++ b/.gitignore @@ -8,18 +8,19 @@ node_modules # testing coverage -# database -prisma/db.sqlite -prisma/db.sqlite-journal - # next.js .next/ out/ next-env.d.ts +# nitro +.nitro/ +.output/ + # expo .expo/ -dist/ +expo-env.d.ts +apps/expo/.gitignore # production build @@ -43,6 +44,7 @@ yarn-error.log* # typescript *.tsbuildinfo +dist/ # turbo .turbo diff --git a/.npmrc b/.npmrc index e165d81..d67f374 100644 --- a/.npmrc +++ b/.npmrc @@ -1,12 +1 @@ -# Expo doesn't play nice with pnpm by default. -# The symbolic links of pnpm break the rules of Expo monorepos. -# @link https://docs.expo.dev/guides/monorepos/#common-issues node-linker=hoisted - -# In order to cache Prisma correctly -public-hoist-pattern[]=*prisma* - -# FIXME: @prisma/client is required by the @acme/auth, -# but we don't want it installed there since it's already -# installed in the @acme/db package -strict-peer-dependencies=false diff --git a/.nvmrc b/.nvmrc index 25bf17f..d5908b9 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -18 \ No newline at end of file +20.12 \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index f974f61..7388076 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,9 +1,14 @@ { "editor.codeActionsOnSave": { - "source.fixAll.eslint": true + "source.fixAll.eslint": "explicit" }, "editor.defaultFormatter": "esbenp.prettier-vscode", "editor.formatOnSave": true, - "eslint.rules.customizations": [{ "rule": "*", "severity": "warn" }], + "eslint.rules.customizations": [ + { + "rule": "*", + "severity": "warn" + } + ], "typescript.tsdk": "node_modules/typescript/lib" -} +} \ No newline at end of file diff --git a/LICENSE b/LICENSE deleted file mode 100644 index b53c1ac..0000000 --- a/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2023 T3 Open Source - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/README.md b/README.md index 8c36d2f..3563d7f 100644 --- a/README.md +++ b/README.md @@ -1,189 +1 @@ -# create-t3-turbo - -turbo2 - -## About - -Ever wondered how to migrate your T3 application into a monorepo? Stop right here! This is the perfect starter repo to get you running with the perfect stack! - -It uses [Turborepo](https://turborepo.org/) and contains: - -``` -.github - └─ workflows - └─ CI with pnpm cache setup -.vscode - └─ Recommended extensions and settings for VSCode users -apps - ├─ expo - | ├─ Expo SDK 48 - | ├─ React Native using React 18 - | ├─ Navigation using Expo Router - | ├─ Tailwind using Nativewind - | └─ Typesafe API calls using tRPC - └─ next.js - ├─ Next.js 13 - ├─ React 18 - ├─ TailwindCSS - └─ E2E Typesafe API Server & Client -packages - ├─ api - | └─ tRPC v10 router definition - ├─ auth - └─ authentication using next-auth. **NOTE: Only for Next.js app, not Expo** - └─ db - └─ typesafe db-calls using Prisma -``` - -## FAQ - -### Can you include Solito? - -No. Solito will not be included in this repo. It is a great tool if you want to share code between your Next.js and Expo app. However, the main purpose of this repo is not the integration between Next.js and Expo - it's the codesplitting of your T3 App into a monorepo, the Expo app is just a bonus example of how you can utilize the monorepo with multiple apps but can just as well be any app such as Vite, Electron, etc. - -Integrating Solito into this repo isn't hard, and there are a few [offical templates](https://github.com/nandorojo/solito/tree/master/example-monorepos) by the creators of Solito that you can use as a reference. - -### What auth solution should I use instead of Next-Auth.js for Expo? - -I've left this kind of open for you to decide. Some options are [Clerk](https://clerk.dev), [Supabase Auth](https://supabase.com/docs/guides/auth), [Firebase Auth](https://firebase.google.com/docs/auth/) or [Auth0](https://auth0.com/docs). Note that if you're dropping the Expo app for something more "browser-like", you can still use Next-Auth.js for those. - -The Clerk.dev team even made an [official template repository](https://github.com/clerkinc/t3-turbo-and-clerk) integrating Clerk.dev with this repo. - -## Quick Start - -To get it running, follow the steps below: - -### Setup dependencies - -```diff -# Install dependencies -pnpm i - -# In packages/db/prisma update schema.prisma provider to use sqlite -# or use your own database provider -- provider = "postgresql" -+ provider = "sqlite" - -# Configure environment variables. -# There is an `.env.example` in the root directory you can use for reference -cp .env.example .env - -# Push the Prisma schema to your database -pnpm db:push -``` - -### Configure Expo `dev`-script - -#### Use iOS Simulator - -1. Make sure you have XCode and XCommand Line Tools installed [as shown on expo docs](https://docs.expo.dev/workflow/ios-simulator/). - > **NOTE:** If you just installed XCode, or if you have updated it, you need to open the simulator manually once before you can run it using the turbo `dev`-script. - -```diff -+ "dev": "expo start --ios", -``` - -3. Run `pnpm dev` at the project root folder. - -> **TIP:** It might be easier to run each app in separate terminal windows so you get the logs from each app separately. This is also required if you want your terminals to be interactive, e.g. to access the Expo QR code. You can run `pnpm --filter expo dev` and `pnpm --filter nextjs dev` to run each app in a separate terminal window. - -#### For Android - -1. Install Android Studio tools [as shown on expo docs](https://docs.expo.dev/workflow/android-studio-emulator/). -2. Change the `dev` script at `apps/expo/package.json` to open the Android emulator. - -```diff -+ "dev": "expo start --android", -``` - -3. Run `pnpm dev` at the project root folder. - -## Deployment - -### Next.js - -#### Prerequisites - -_We do not recommend deploying a SQLite database on serverless environments since the data wouldn't be persisted. I provisioned a quick Postgresql database on [Railway](https://railway.app), but you can of course use any other database provider. Make sure the prisma schema is updated to use the correct database._ - -#### Deploy to Vercel - -Let's deploy the Next.js application to [Vercel](https://vercel.com/). If you have ever deployed a Turborepo app there, the steps are quite straightforward. You can also read the [official Turborepo guide](https://vercel.com/docs/concepts/monorepos/turborepo) on deploying to Vercel. - -1. Create a new project on Vercel, select the `apps/nextjs` folder as the root directory and apply the following build settings: - -Vercel deployment settings - -> The install command filters out the expo package and saves a few second (and cache size) of dependency installation. The build command makes us build the application using Turbo. - -2. Add your `DATABASE_URL` environment variable. - -3. Done! Your app should successfully deploy. Assign your domain and use that instead of `localhost` for the `url` in the Expo app so that your Expo app can communicate with your backend when you are not in development. - -### Expo - -Deploying your Expo application works slightly differently compared to Next.js on the web. Instead of "deploying" your app online, you need to submit production builds of your app to the app stores, like [Apple App Store](https://www.apple.com/app-store/) and [Google Play](https://play.google.com/store/apps). You can read the full [Distributing your app](https://docs.expo.dev/distribution/introduction/), including best practices, in the Expo docs. - -1. Let's start by setting up [EAS Build](https://docs.expo.dev/build/introduction/), which is short for Expo Application Services. The build service helps you create builds of your app, without requiring a full native development setup. The commands below are a summary of [Creating your first build](https://docs.expo.dev/build/setup/). - - ```bash - // Install the EAS CLI - $ pnpm add -g eas-cli - - // Log in with your Expo account - $ eas login - - // Configure your Expo app - $ cd apps/expo - $ eas build:configure - ``` - -2. After the initial setup, you can create your first build. You can build for Android and iOS platforms and use different [**eas.json** build profiles](https://docs.expo.dev/build-reference/eas-json/) to create production builds or development, or test builds. Let's make a production build for iOS. - - ``` - $ eas build --platform ios --profile production - ``` - - > If you don't specify the `--profile` flag, EAS uses the `production` profile by default. - -3. Now that you have your first production build, you can submit this to the stores. [EAS Submit](https://docs.expo.dev/submit/introduction/) can help you send the build to the stores. - - ``` - $ eas submit --platform ios --latest - ``` - - > You can also combine build and submit in a single command, using `eas build ... --auto-submit`. - -4. Before you can get your app in the hands of your users, you'll have to provide additional information to the app stores. This includes screenshots, app information, privacy policies, etc. _While still in preview_, [EAS Metadata](https://docs.expo.dev/eas/metadata/) can help you with most of this information. - -5. Once everything is approved, your users can finally enjoy your app. Let's say you spotted a small typo; you'll have to create a new build, submit it to the stores, and wait for approval before you can resolve this issue. In these cases, you can use EAS Update to quickly send a small bugfix to your users without going through this long process. Let's start by setting up EAS Update. - - The steps below summarize the [Getting started with EAS Update](https://docs.expo.dev/eas-update/getting-started/#configure-your-project) guide. - - ```bash - // Add the `expo-updates` library to your Expo app - $ cd apps/expo - $ pnpm expo install expo-updates - - // Configure EAS Update - $ eas update:configure - ``` - -6. Before we can send out updates to your app, you have to create a new build and submit it to the app stores. For every change that includes native APIs, you have to rebuild the app and submit the update to the app stores. See steps 2 and 3. - -7. Now that everything is ready for updates, let's create a new update for `production` builds. With the `--auto` flag, EAS Update uses your current git branch name and commit message for this update. See [How EAS Update works](https://docs.expo.dev/eas-update/how-eas-update-works/#publishing-an-update) for more information. - - ```bash - $ cd apps/expo - $ eas update --auto - ``` - - > Your OTA (Over The Air) updates must always follow the app store's rules. You can't change your app's primary functionality without getting app store approval. But this is a fast way to update your app for minor changes and bug fixes. - -8. Done! Now that you have created your production build, submitted it to the stores, and installed EAS Update, you are ready for anything! - -## References - -The stack originates from [create-t3-app](https://github.com/t3-oss/create-t3-app). - -A [blog post](https://jumr.dev/blog/t3-turbo) where I wrote how to migrate a T3 app into this. +# Ponto \ No newline at end of file diff --git a/apps/expo/.expo-shared/assets.json b/apps/expo/.expo-shared/assets.json deleted file mode 100644 index 1e6decf..0000000 --- a/apps/expo/.expo-shared/assets.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "12bb71342c6255bbf50437ec8f4441c083f47cdb74bd89160c15e4f43e52a1cb": true, - "40b842e832070c58deac6aa9e08fa459302ee3f9da492c7e77d93d2fbf4a56fd": true -} diff --git a/apps/expo/app.config.ts b/apps/expo/app.config.ts deleted file mode 100644 index 16a1066..0000000 --- a/apps/expo/app.config.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { ConfigContext, ExpoConfig } from "@expo/config"; - -const defineConfig = (_ctx: ConfigContext): ExpoConfig => ({ - name: "expo", - slug: "expo", - scheme: "expo", - version: "1.0.0", - orientation: "portrait", - icon: "./assets/icon.png", - userInterfaceStyle: "light", - splash: { - image: "./assets/icon.png", - resizeMode: "contain", - backgroundColor: "#2e026d", - }, - updates: { - fallbackToCacheTimeout: 0, - }, - assetBundlePatterns: ["**/*"], - ios: { - supportsTablet: true, - bundleIdentifier: "your.bundle.identifier", - }, - android: { - adaptiveIcon: { - foregroundImage: "./assets/icon.png", - backgroundColor: "#2e026d", - }, - }, - extra: { - eas: { - projectId: "your-project-id", - }, - }, - plugins: ["./expo-plugins/with-modify-gradle.js"], -}); - -export default defineConfig; diff --git a/apps/expo/app/_layout.tsx b/apps/expo/app/_layout.tsx deleted file mode 100644 index e2b1cf4..0000000 --- a/apps/expo/app/_layout.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import React from "react"; -import { SafeAreaProvider } from "react-native-safe-area-context"; -import { Stack } from "expo-router"; -import { StatusBar } from "expo-status-bar"; - -import { TRPCProvider } from "../src/utils/api"; - -// This is the main layout of the app -// It wraps your pages with the providers they need -const RootLayout = () => { - return ( - - - {/* - The Stack component displays the current page. - It also allows you to configure your screens - */} - - - - - ); -}; - -export default RootLayout; diff --git a/apps/expo/app/index.tsx b/apps/expo/app/index.tsx deleted file mode 100644 index 0c988ad..0000000 --- a/apps/expo/app/index.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import React from "react"; -import { Text, View } from "react-native"; -import { SafeAreaView } from "react-native-safe-area-context"; -import { Stack } from "expo-router"; - -const Index = () => { - return ( - - - - - Ponto - - - - ); -}; - -export default Index; diff --git a/apps/expo/assets/icon.png b/apps/expo/assets/icon.png deleted file mode 100644 index 8ff460d..0000000 Binary files a/apps/expo/assets/icon.png and /dev/null differ diff --git a/apps/expo/babel.config.js b/apps/expo/babel.config.js deleted file mode 100644 index 7079d14..0000000 --- a/apps/expo/babel.config.js +++ /dev/null @@ -1,7 +0,0 @@ -module.exports = function (api) { - api.cache(true); - return { - plugins: ["nativewind/babel", require.resolve("expo-router/babel")], - presets: ["babel-preset-expo"], - }; -}; diff --git a/apps/expo/eas.json b/apps/expo/eas.json deleted file mode 100644 index ecb05ac..0000000 --- a/apps/expo/eas.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "cli": { "version": ">= 3.3.0" }, - "build": { - "development": { - "developmentClient": true, - "distribution": "internal" - }, - "preview": { - "ios": { "simulator": true, "resourceClass": "m1-medium" }, - "distribution": "internal" - }, - "production": {} - }, - "submit": { "production": {} } -} diff --git a/apps/expo/expo-plugins/with-modify-gradle.js b/apps/expo/expo-plugins/with-modify-gradle.js deleted file mode 100644 index 7e61eb6..0000000 --- a/apps/expo/expo-plugins/with-modify-gradle.js +++ /dev/null @@ -1,43 +0,0 @@ -/* eslint-disable no-undef */ - -// This plugin is required for fixing `.apk` build issue -// It appends Expo and RN versions into the `build.gradle` file -// References: -// https://github.com/t3-oss/create-t3-turbo/issues/120 -// https://github.com/expo/expo/issues/18129 - -/** @type {import("@expo/config-plugins").ConfigPlugin} */ -module.exports = (config) => { - return require("@expo/config-plugins").withProjectBuildGradle( - config, - (config) => { - if (!config.modResults.contents.includes("ext.getPackageJsonVersion =")) { - config.modResults.contents = config.modResults.contents.replace( - "buildscript {", - `buildscript { - ext.getPackageJsonVersion = { packageName -> - new File(['node', '--print', "JSON.parse(require('fs').readFileSync(require.resolve('\${packageName}/package.json'), 'utf-8')).version"].execute(null, rootDir).text.trim()) - }`, - ); - } - - if (!config.modResults.contents.includes("reactNativeVersion =")) { - config.modResults.contents = config.modResults.contents.replace( - "ext {", - `ext { - reactNativeVersion = "\${ext.getPackageJsonVersion('react-native')}"`, - ); - } - - if (!config.modResults.contents.includes("expoPackageVersion =")) { - config.modResults.contents = config.modResults.contents.replace( - "ext {", - `ext { - expoPackageVersion = "\${ext.getPackageJsonVersion('expo')}"`, - ); - } - - return config; - }, - ); -}; diff --git a/apps/expo/index.ts b/apps/expo/index.ts deleted file mode 100644 index 9188892..0000000 --- a/apps/expo/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -// This is the entry point for the Expo app. -// It is responsible for setting up the router, root component and loading pages. -import "expo-router/entry"; diff --git a/apps/expo/metro.config.js b/apps/expo/metro.config.js deleted file mode 100644 index 3cfa26f..0000000 --- a/apps/expo/metro.config.js +++ /dev/null @@ -1,24 +0,0 @@ -// Learn more: https://docs.expo.dev/guides/monorepos/ -const { getDefaultConfig } = require("expo/metro-config"); -const path = require("path"); - -const projectRoot = __dirname; -const workspaceRoot = path.resolve(projectRoot, "../.."); - -// Create the default Metro config -const config = getDefaultConfig(projectRoot); - -// Add the additional `cjs` extension to the resolver -config.resolver.sourceExts.push("cjs"); - -// 1. Watch all files within the monorepo -config.watchFolders = [workspaceRoot]; -// 2. Let Metro know where to resolve packages and in what order -config.resolver.nodeModulesPaths = [ - path.resolve(projectRoot, "node_modules"), - path.resolve(workspaceRoot, "node_modules"), -]; -// 3. Force Metro to resolve (sub)dependencies only from the `nodeModulesPaths` -// config.resolver.disableHierarchicalLookup = true; - -module.exports = config; diff --git a/apps/expo/package.json b/apps/expo/package.json deleted file mode 100644 index effa40b..0000000 --- a/apps/expo/package.json +++ /dev/null @@ -1,50 +0,0 @@ -{ - "name": "@acme/expo", - "version": "0.1.0", - "main": "index.ts", - "scripts": { - "clean": "rm -rf .expo .turbo node_modules", - "dev": "expo start --android", - "dev:android": "expo start --android", - "dev:ios": "expo start --ios", - "lint": "eslint .", - "type-check": "tsc --noEmit" - }, - "dependencies": { - "@acme/api": "*", - "@acme/tailwind-config": "*", - "@shopify/flash-list": "1.4.0", - "@tanstack/react-query": "^4.23.0", - "@trpc/client": "^10.9.0", - "@trpc/react-query": "^10.9.0", - "@trpc/server": "^10.9.0", - "expo": "48.0.0-beta.2", - "expo-constants": "~14.2.1", - "expo-linking": "~4.0.1", - "expo-router": "^1.0.0-rc5", - "expo-splash-screen": "~0.18.1", - "expo-status-bar": "~1.4.4", - "nativewind": "^2.0.11", - "react": "18.2.0", - "react-dom": "18.2.0", - "react-native": "0.71.2", - "react-native-safe-area-context": "4.5.0", - "react-native-screens": "~3.19.0" - }, - "devDependencies": { - "@babel/core": "^7.20.0", - "@babel/preset-env": "^7.20.0", - "@babel/runtime": "^7.20.0", - "@expo/config-plugins": "^6.0.0", - "@types/react": "^18.0.27", - "eslint": "^8.33.0", - "postcss": "^8.4.21", - "tailwindcss": "^3.2.4", - "typescript": "^4.9.5" - }, - "overrides": { - "metro": "0.73.7", - "metro-resolver": "0.73.7" - }, - "private": true -} diff --git a/apps/expo/src/types/nativewind.d.ts b/apps/expo/src/types/nativewind.d.ts deleted file mode 100644 index a13e313..0000000 --- a/apps/expo/src/types/nativewind.d.ts +++ /dev/null @@ -1 +0,0 @@ -/// diff --git a/apps/expo/src/utils/api.tsx b/apps/expo/src/utils/api.tsx deleted file mode 100644 index 025d918..0000000 --- a/apps/expo/src/utils/api.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import React from "react"; -import Constants from "expo-constants"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { httpBatchLink } from "@trpc/client"; -import { createTRPCReact } from "@trpc/react-query"; -import { inferRouterInputs, inferRouterOutputs } from "@trpc/server"; -import type { AppRouter } from "@acme/api"; -import { transformer } from "@acme/api/transformer"; - -/** - * A set of typesafe hooks for consuming your API. - */ -export const api = createTRPCReact(); - -/** - * Inference helpers for input types - * @example type HelloInput = RouterInputs['example']['hello'] - **/ -export type RouterInputs = inferRouterInputs; - -/** - * Inference helpers for output types - * @example type HelloOutput = RouterOutputs['example']['hello'] - **/ -export type RouterOutputs = inferRouterOutputs; - -/** - * Extend this function when going to production by - * setting the baseUrl to your production API URL. - */ -const getBaseUrl = () => { - /** - * Gets the IP address of your host-machine. If it cannot automatically find it, - * you'll have to manually set it. NOTE: Port 3000 should work for most but confirm - * you don't have anything else running on it, or you'd have to change it. - */ - const localhost = Constants.manifest?.debuggerHost?.split(":")[0]; - if (!localhost) - throw new Error("failed to get localhost, configure it manually"); - return `http://${localhost}:3000`; -}; - -/** - * A wrapper for your app that provides the TRPC context. - * Use only in _app.tsx - */ -export const TRPCProvider: React.FC<{ children: React.ReactNode }> = ({ - children, -}) => { - const [queryClient] = React.useState(() => new QueryClient()); - const [trpcClient] = React.useState(() => - api.createClient({ - transformer, - links: [ - httpBatchLink({ - url: `${getBaseUrl()}/api/trpc`, - }), - ], - }), - ); - - return ( - - {children} - - ); -}; diff --git a/apps/expo/tailwind.config.cjs b/apps/expo/tailwind.config.cjs deleted file mode 100644 index 9770f69..0000000 --- a/apps/expo/tailwind.config.cjs +++ /dev/null @@ -1,5 +0,0 @@ -/** @type {import("tailwindcss").Config} */ -module.exports = { - presets: [require("@acme/tailwind-config")], - content: ["./app/**/*.{ts,tsx}", "./src/**/*.{ts,tsx}"], -}; diff --git a/apps/expo/tsconfig.json b/apps/expo/tsconfig.json deleted file mode 100644 index cadaf49..0000000 --- a/apps/expo/tsconfig.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "extends": "../../tsconfig.json", - "compilerOptions": { - "jsx": "react-native" - }, - "include": ["app", "expo-plugins", "src", "app.config.ts", "index.ts"] -} diff --git a/apps/nextjs/.eslintrc.cjs b/apps/nextjs/.eslintrc.cjs deleted file mode 100644 index ce31e6d..0000000 --- a/apps/nextjs/.eslintrc.cjs +++ /dev/null @@ -1,6 +0,0 @@ -/** @type {import("eslint").Linter.Config} */ -const config = { - extends: "next/core-web-vitals", -}; - -module.exports = config; diff --git a/apps/nextjs/README.md b/apps/nextjs/README.md index cc40526..437b0b8 100644 --- a/apps/nextjs/README.md +++ b/apps/nextjs/README.md @@ -10,7 +10,7 @@ If you are not familiar with the different technologies used in this project, pl - [Next.js](https://nextjs.org) - [NextAuth.js](https://next-auth.js.org) -- [Prisma](https://prisma.io) +- [Drizzle](https://orm.drizzle.team) - [Tailwind CSS](https://tailwindcss.com) - [tRPC](https://trpc.io) diff --git a/apps/nextjs/eslint.config.js b/apps/nextjs/eslint.config.js new file mode 100644 index 0000000..3071d3d --- /dev/null +++ b/apps/nextjs/eslint.config.js @@ -0,0 +1,14 @@ +import baseConfig, { restrictEnvAccess } from "@acme/eslint-config/base"; +import nextjsConfig from "@acme/eslint-config/nextjs"; +import reactConfig from "@acme/eslint-config/react"; + +/** @type {import('typescript-eslint').Config} */ +export default [ + { + ignores: [".next/**"], + }, + ...baseConfig, + ...reactConfig, + ...nextjsConfig, + ...restrictEnvAccess, +]; diff --git a/apps/nextjs/next.config.js b/apps/nextjs/next.config.js new file mode 100644 index 0000000..ab74538 --- /dev/null +++ b/apps/nextjs/next.config.js @@ -0,0 +1,29 @@ +import { fileURLToPath } from "url"; +import createJiti from "jiti"; + +// Import env files to validate at build time. Use jiti so we can load .ts files in here. +createJiti(fileURLToPath(import.meta.url))("./src/env"); + +/** @type {import("next").NextConfig} */ +const config = { + reactStrictMode: true, + + /** Enables hot reloading for local packages without a build step */ + transpilePackages: [ + "@acme/api", + "@acme/auth", + "@acme/db", + "@acme/ui", + "@acme/validators", + ], + + /** We already do linting and typechecking as separate tasks in CI */ + eslint: { ignoreDuringBuilds: true }, + typescript: { ignoreBuildErrors: true }, + + experimental: { + typedRoutes: true, + }, +}; + +export default config; diff --git a/apps/nextjs/next.config.mjs b/apps/nextjs/next.config.mjs deleted file mode 100644 index 26310bf..0000000 --- a/apps/nextjs/next.config.mjs +++ /dev/null @@ -1,24 +0,0 @@ -/** - * Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. - * This is especially useful for Docker builds and Linting. - */ -!process.env.SKIP_ENV_VALIDATION && (await import("./src/env.mjs")); - -/** @type {import("next").NextConfig} */ -const config = { - reactStrictMode: true, - /** Enables hot reloading for local packages without a build step */ - transpilePackages: ["@acme/api", "@acme/auth", "@acme/db"], - /** We already do linting and typechecking as separate tasks in CI */ - eslint: { ignoreDuringBuilds: !!process.env.CI }, - typescript: { ignoreBuildErrors: !!process.env.CI }, - redirects: async () => [ - { - source: "/", - destination: "/team", - permanent: true, - }, - ], -}; - -export default config; diff --git a/apps/nextjs/package.json b/apps/nextjs/package.json index c013d7d..ecd7f82 100644 --- a/apps/nextjs/package.json +++ b/apps/nextjs/package.json @@ -2,49 +2,57 @@ "name": "@acme/nextjs", "version": "0.1.0", "private": true, + "type": "module", "scripts": { "build": "pnpm with-env next build", - "clean": "rm -rf .next .turbo node_modules", + "clean": "git clean -xdf .next .turbo node_modules", "dev": "pnpm with-env next dev", - "lint": "SKIP_ENV_VALIDATION=1 next lint --fix", - "start": "next start", - "type-check": "tsc --noEmit", + "format": "prettier --check . --ignore-path ../../.gitignore", + "lint": "eslint", + "start": "pnpm with-env next start", + "typecheck": "tsc --noEmit", "with-env": "dotenv -e ../../.env --" }, "dependencies": { - "@acme/api": "*", - "@acme/auth": "*", - "@acme/db": "*", - "@acme/tailwind-config": "*", - "@hookform/resolvers": "^2.9.10", - "@radix-ui/react-dropdown-menu": "^2.0.2", - "@tanstack/react-query": "^4.23.0", - "@trpc/client": "^10.9.0", - "@trpc/next": "^10.9.0", - "@trpc/react-query": "^10.9.0", - "@trpc/server": "^10.9.0", + "@acme/api": "workspace:*", + "@acme/auth": "workspace:*", + "@acme/db": "workspace:*", + "@acme/ui": "workspace:*", + "@acme/validators": "workspace:*", + "@hookform/resolvers": "^3.3.4", + "@t3-oss/env-nextjs": "^0.10.1", + "@tanstack/react-query": "^5.32.0", + "@trpc/client": "11.0.0-rc.334", + "@trpc/react-query": "11.0.0-rc.334", + "@trpc/server": "11.0.0-rc.334", "@ts-react/form": "^1.1.3", - "@vercel/analytics": "^0.1.8", - "clsx": "^1.2.1", + "@vercel/analytics": "^1.2.2", + "@vercel/speed-insights": "^1.0.10", + "clsx": "^2.1.1", "dayjs": "^1.11.7", - "next": "^13.1.6", - "next-auth": "^4.19.0", - "nextjs-progressbar": "^0.0.16", - "react": "18.2.0", - "react-dom": "18.2.0", - "react-hook-form": "^7.42.1", - "zod": "^3.20.2" + "geist": "^1.3.0", + "lucide-react": "^0.376.0", + "next": "^14.2.3", + "react": "18.3.1", + "react-dom": "18.3.1", + "react-hook-form": "^7.51.1", + "superjson": "2.2.1", + "zod": "^3.23.4" }, "devDependencies": { - "@types/node": "^18.0.0", - "@types/react": "^18.0.27", - "@types/react-dom": "^18.0.10", - "autoprefixer": "^10.4.13", - "dotenv-cli": "^7.0.0", - "eslint": "^8.33.0", - "eslint-config-next": "^13.1.6", - "postcss": "^8.4.21", - "tailwindcss": "^3.2.4", - "typescript": "^4.9.5" - } + "@acme/eslint-config": "workspace:*", + "@acme/prettier-config": "workspace:*", + "@acme/tailwind-config": "workspace:*", + "@acme/tsconfig": "workspace:*", + "@types/node": "^20.12.5", + "@types/react": "^18.3.1", + "@types/react-dom": "^18.3.0", + "dotenv-cli": "^7.4.1", + "eslint": "^9.1.1", + "jiti": "^1.21.0", + "prettier": "^3.2.5", + "tailwindcss": "^3.4.3", + "typescript": "^5.4.5" + }, + "prettier": "@acme/prettier-config" } diff --git a/apps/nextjs/postcss.config.cjs b/apps/nextjs/postcss.config.cjs index ba7da8c..ee5f90b 100644 --- a/apps/nextjs/postcss.config.cjs +++ b/apps/nextjs/postcss.config.cjs @@ -1,2 +1,5 @@ -// @ts-ignore -module.exports = require("@acme/tailwind-config/postcss"); +module.exports = { + plugins: { + tailwindcss: {}, + }, +}; diff --git a/apps/nextjs/src/app/_components/header.tsx b/apps/nextjs/src/app/_components/header.tsx new file mode 100644 index 0000000..cfe7e3e --- /dev/null +++ b/apps/nextjs/src/app/_components/header.tsx @@ -0,0 +1,54 @@ +import Link from "next/link"; + +import { auth, signIn, signOut } from "@acme/auth"; +import { Button } from "@acme/ui/button"; + +export function Header() { + return ( +
+ + Ponto + + + +
+ ); +} + +async function Auth() { + const session = await auth(); + + if (!session) { + return ( +
+ +
+ ); + } + + return ( +
+
+ +
+
+ ); +} diff --git a/apps/nextjs/src/app/_components/teams.tsx b/apps/nextjs/src/app/_components/teams.tsx new file mode 100644 index 0000000..9b38268 --- /dev/null +++ b/apps/nextjs/src/app/_components/teams.tsx @@ -0,0 +1,59 @@ +import Link from "next/link"; + +import { Badge } from "@acme/ui/badge"; +import { Button } from "@acme/ui/button"; + +import { api } from "~/trpc/server"; + +export async function Teams() { + const teams = await api.team.all(); + + return ( +
+ {teams.length === 0 ? ( +
+ Você ainda não faz parte de nenhum time +
+ ) : ( + <> +

+ Times +

+
+ {teams.map((team) => ( + + ))} +
+ + )} + +
+ + +
ou
+ + +
+
+ ); +} diff --git a/apps/nextjs/src/app/api/auth/[...nextauth]/route.ts b/apps/nextjs/src/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..cf57c05 --- /dev/null +++ b/apps/nextjs/src/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,4 @@ +export { GET, POST } from "@acme/auth"; + +export const runtime = "edge"; +export const preferredRegion = ["iad1"]; diff --git a/apps/nextjs/src/app/api/trpc/[trpc]/route.ts b/apps/nextjs/src/app/api/trpc/[trpc]/route.ts new file mode 100644 index 0000000..b1ebe86 --- /dev/null +++ b/apps/nextjs/src/app/api/trpc/[trpc]/route.ts @@ -0,0 +1,47 @@ +import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; + +import { appRouter, createTRPCContext } from "@acme/api"; +import { auth } from "@acme/auth"; + +export const runtime = "edge"; +export const preferredRegion = ["iad1"]; + +/** + * Configure basic CORS headers + * You should extend this to match your needs + */ +const setCorsHeaders = (res: Response) => { + res.headers.set("Access-Control-Allow-Origin", "*"); + res.headers.set("Access-Control-Request-Method", "*"); + res.headers.set("Access-Control-Allow-Methods", "OPTIONS, GET, POST"); + res.headers.set("Access-Control-Allow-Headers", "*"); +}; + +export const OPTIONS = () => { + const response = new Response(null, { + status: 204, + }); + setCorsHeaders(response); + return response; +}; + +const handler = auth(async (req) => { + const response = await fetchRequestHandler({ + endpoint: "/api/trpc", + router: appRouter, + req, + createContext: () => + createTRPCContext({ + session: req.auth, + headers: req.headers, + }), + onError({ error, path }) { + console.error(`>>> tRPC Error on '${path}'`, error); + }, + }); + + setCorsHeaders(response); + return response; +}); + +export { handler as GET, handler as POST }; diff --git a/apps/nextjs/src/app/error.tsx b/apps/nextjs/src/app/error.tsx new file mode 100644 index 0000000..cab1aed --- /dev/null +++ b/apps/nextjs/src/app/error.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { useRouter } from "next/navigation"; + +import { Button } from "@acme/ui/button"; + +export default function Error() { + const router = useRouter(); + + return ( +
+

Algo deu errado

+ +
+ ); +} diff --git a/apps/nextjs/src/app/globals.css b/apps/nextjs/src/app/globals.css new file mode 100644 index 0000000..0ad2651 --- /dev/null +++ b/apps/nextjs/src/app/globals.css @@ -0,0 +1,50 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 20 14.3% 4.1%; + --card: 0 0% 100%; + --card-foreground: 20 14.3% 4.1%; + --popover: 0 0% 100%; + --popover-foreground: 20 14.3% 4.1%; + --primary: 24.6 95% 53.1%; + --primary-foreground: 60 9.1% 97.8%; + --secondary: 60 4.8% 95.9%; + --secondary-foreground: 24 9.8% 10%; + --muted: 60 4.8% 95.9%; + --muted-foreground: 25 5.3% 44.7%; + --accent: 60 4.8% 95.9%; + --accent-foreground: 24 9.8% 10%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 60 9.1% 97.8%; + --border: 20 5.9% 90%; + --input: 20 5.9% 90%; + --ring: 24.6 95% 53.1%; + --radius: 1rem; + } + + .dark { + --background: 20 14.3% 4.1%; + --foreground: 60 9.1% 97.8%; + --card: 20 14.3% 4.1%; + --card-foreground: 60 9.1% 97.8%; + --popover: 20 14.3% 4.1%; + --popover-foreground: 60 9.1% 97.8%; + --primary: 20.5 90.2% 48.2%; + --primary-foreground: 60 9.1% 97.8%; + --secondary: 12 6.5% 15.1%; + --secondary-foreground: 60 9.1% 97.8%; + --muted: 12 6.5% 15.1%; + --muted-foreground: 24 5.4% 63.9%; + --accent: 12 6.5% 15.1%; + --accent-foreground: 60 9.1% 97.8%; + --destructive: 0 72.2% 50.6%; + --destructive-foreground: 60 9.1% 97.8%; + --border: 12 6.5% 15.1%; + --input: 12 6.5% 15.1%; + --ring: 20.5 90.2% 48.2%; + } +} diff --git a/apps/nextjs/src/app/layout.tsx b/apps/nextjs/src/app/layout.tsx new file mode 100644 index 0000000..7ced031 --- /dev/null +++ b/apps/nextjs/src/app/layout.tsx @@ -0,0 +1,76 @@ +import type { Metadata, Viewport } from "next"; +import { Analytics } from "@vercel/analytics/next"; +import { SpeedInsights } from "@vercel/speed-insights/next"; +import { GeistMono } from "geist/font/mono"; +import { GeistSans } from "geist/font/sans"; + +import { cn } from "@acme/ui"; +import { ThemeProvider, ThemeToggle } from "@acme/ui/theme"; +import { Toaster } from "@acme/ui/toast"; + +import { env } from "~/env"; +import { TRPCReactProvider } from "~/trpc/react"; +import { Header } from "./_components/header"; + +import "~/app/globals.css"; + +export const runtime = "edge"; +export const preferredRegion = ["iad1"]; + +export const metadata: Metadata = { + metadataBase: new URL( + env.VERCEL_ENV === "production" + ? "ponto-six.vercel.app" + : "http://localhost:3000", + ), + title: "Ponto", + description: "Simples e Ponto", + openGraph: { + title: "Ponto", + description: "Simples e Ponto", + url: "https://ponto-six.vercel.app", + siteName: "Ponto", + }, + twitter: { + card: "summary_large_image", + site: "@erickreis25", + creator: "@erickreis25", + }, +}; + +export const viewport: Viewport = { + themeColor: [ + { media: "(prefers-color-scheme: light)", color: "white" }, + { media: "(prefers-color-scheme: dark)", color: "black" }, + ], +}; + +export default function RootLayout(props: { children: React.ReactNode }) { + return ( + + + + +
+
+
{props.children}
+
+
+ +
+ +
+ +
+ + + + + ); +} diff --git a/apps/nextjs/src/app/page.tsx b/apps/nextjs/src/app/page.tsx new file mode 100644 index 0000000..11c9b93 --- /dev/null +++ b/apps/nextjs/src/app/page.tsx @@ -0,0 +1,31 @@ +import { Suspense } from "react"; + +import { auth } from "@acme/auth"; + +import { Teams } from "./_components/teams"; + +export const runtime = "edge"; + +export default async function HomePage() { + const session = await auth(); + + return ( +
+
+ {session ? ( +
+ Loading...
+ } + > + + +
+ ) : ( + Faça o login para ver seus times + )} + +
+ ); +} diff --git a/apps/nextjs/src/components/copy-text.tsx b/apps/nextjs/src/app/team/[teamId]/@admin/_components/copy-text.tsx similarity index 78% rename from apps/nextjs/src/components/copy-text.tsx rename to apps/nextjs/src/app/team/[teamId]/@admin/_components/copy-text.tsx index e0b147b..b3767cd 100644 --- a/apps/nextjs/src/components/copy-text.tsx +++ b/apps/nextjs/src/app/team/[teamId]/@admin/_components/copy-text.tsx @@ -1,6 +1,9 @@ +"use client"; + import { useState } from "react"; -import { Button } from "~/components/button"; +import { Button } from "@acme/ui/button"; +import { Input } from "@acme/ui/input"; export const CopyText = ({ copyText }: { copyText: string }) => { const [isCopied, setIsCopied] = useState(false); @@ -28,9 +31,9 @@ export const CopyText = ({ copyText }: { copyText: string }) => { return (
- +
); diff --git a/apps/nextjs/src/app/team/[teamId]/@admin/page.tsx b/apps/nextjs/src/app/team/[teamId]/@admin/page.tsx new file mode 100644 index 0000000..cdcdbdd --- /dev/null +++ b/apps/nextjs/src/app/team/[teamId]/@admin/page.tsx @@ -0,0 +1,52 @@ +import { headers } from "next/headers"; +import Link from "next/link"; +import { redirect } from "next/navigation"; + +import { Badge } from "@acme/ui/badge"; +import { Button } from "@acme/ui/button"; + +import { api } from "~/trpc/server"; +import { CopyText } from "./_components/copy-text"; + +export default async function Page({ params }: { params: { teamId: string } }) { + const team = await api.team.get(params.teamId); + + if (!team) { + redirect("/"); + } + + const host = headers().get("host") ?? ""; + const teamMembers = await api.teamMember.all(team.id); + + return ( + <> +

Convide novos membros

+ + + +

Membros

+ +
+ {teamMembers.map((member) => ( + + ))} +
+ + ); +} diff --git a/apps/nextjs/src/app/team/[teamId]/@admin/user/[userId]/page.tsx b/apps/nextjs/src/app/team/[teamId]/@admin/user/[userId]/page.tsx new file mode 100644 index 0000000..3eb4b44 --- /dev/null +++ b/apps/nextjs/src/app/team/[teamId]/@admin/user/[userId]/page.tsx @@ -0,0 +1,20 @@ +import { redirect } from "next/navigation"; + +import { api } from "~/trpc/server"; +import { UserInfo } from "../../../_components/user-info"; + +export default async function Page({ + params: { teamId, userId }, +}: { + params: { teamId: string; userId: string }; +}) { + const teamMember = await api.teamMember.get({ teamId, userId }); + + if (!teamMember) { + redirect(`/team/${teamId}`); + } + + const history = await api.timeRecord.history({ teamId, userId }); + + return ; +} diff --git a/apps/nextjs/src/pages/team/[teamId]/import.tsx b/apps/nextjs/src/app/team/[teamId]/@tabs/history/[year]/[month]/_components/import.tsx similarity index 65% rename from apps/nextjs/src/pages/team/[teamId]/import.tsx rename to apps/nextjs/src/app/team/[teamId]/@tabs/history/[year]/[month]/_components/import.tsx index 6e4c6ed..9945b11 100644 --- a/apps/nextjs/src/pages/team/[teamId]/import.tsx +++ b/apps/nextjs/src/app/team/[teamId]/@tabs/history/[year]/[month]/_components/import.tsx @@ -1,22 +1,15 @@ +"use client"; + import { useState } from "react"; -import { InferGetServerSidePropsType, NextPage } from "next"; -import { useRouter } from "next/router"; -import clsx from "clsx"; -import { z } from "zod"; - -import { api } from "~/utils/api"; -import dayjs, { Dayjs, displayTime } from "~/utils/dayjs"; -import { createSSR } from "~/utils/ssr"; -import { Button } from "~/components/button"; - -export const getServerSideProps = createSSR( - z.object({ - teamId: z.string().cuid(), - }), - async (ssr, { teamId }) => { - await ssr.teamMember.get.prefetch({ teamId }); - }, -); +import { useRouter } from "next/navigation"; + +import { cn } from "@acme/ui"; +import { Button } from "@acme/ui/button"; +import { Input } from "@acme/ui/input"; + +import type { Dayjs } from "~/utils/dayjs"; +import { api } from "~/trpc/react"; +import dayjs, { displayTime } from "~/utils/dayjs"; const allowedCsvHeaders = [ "DIA", @@ -31,10 +24,8 @@ type AllowedCsvHeaders = (typeof allowedCsvHeaders)[number]; const isAllowedCsvHeader = (header: unknown): header is AllowedCsvHeaders => allowedCsvHeaders.includes(header as AllowedCsvHeaders); -const Import: NextPage< - InferGetServerSidePropsType -> = ({ teamId }) => { - const { back } = useRouter(); +export function Import({ teamId }: { teamId: string }) { + const router = useRouter(); const [file, setFile] = useState(); const [array, setArray] = useState< Record[] @@ -43,7 +34,7 @@ const Import: NextPage< const { mutate } = api.timeRecord.batch.useMutation({ onSuccess: () => { - back(); + router.back(); }, }); @@ -80,35 +71,37 @@ const Import: NextPage< const values = row.split(","); let date: Dayjs; - const obj = csvHeader.reduce((object, header, index) => { - const value = values[index]; - if (!value) return object; - - if (header === "DIA") { - const cleanValue = value.split(" ")[0]; - if (!cleanValue) return object; - const [day, month, year] = cleanValue.split("/"); - if (!day || !month || !year) return object; - - date = dayjs() - .year(+year) - .month(+month - 1) - .date(+day); - - object[header] = date; - } else { - if (!date) return object; - const [hour, minute] = value.split(":"); - if (!hour || !minute) { - object[header] = undefined; - return object; + const obj = csvHeader.reduce( + (object, header, index) => { + const value = values[index]; + if (!value) return object; + + if (header === "DIA") { + const cleanValue = value.split(" ")[0]; + if (!cleanValue) return object; + const [day, month, year] = cleanValue.split("/"); + if (!day || !month || !year) return object; + + date = dayjs() + .year(+year) + .month(+month - 1) + .date(+day); + + object[header] = date; + } else { + const [hour, minute] = value.split(":"); + if (!hour || !minute) { + object[header] = undefined; + return object; + } + + object[header] = date.hour(+hour).minute(+minute); } - object[header] = date.hour(+hour).minute(+minute); - } - - return object; - }, {} as Record); + return object; + }, + {} as Record, + ); return obj; }); @@ -131,10 +124,9 @@ const Import: NextPage< return ( <> -
- {allowedCsvHeaders.map((key, i) => ( - + {key} ))} @@ -194,6 +186,4 @@ const Import: NextPage< )} ); -}; - -export default Import; +} diff --git a/apps/nextjs/src/app/team/[teamId]/@tabs/history/[year]/[month]/page.tsx b/apps/nextjs/src/app/team/[teamId]/@tabs/history/[year]/[month]/page.tsx new file mode 100644 index 0000000..3efa522 --- /dev/null +++ b/apps/nextjs/src/app/team/[teamId]/@tabs/history/[year]/[month]/page.tsx @@ -0,0 +1,291 @@ +"use client"; + +import { useMemo } from "react"; +import Link from "next/link"; +import { CalendarIcon, ChevronLeft, ChevronRight, FileUp } from "lucide-react"; + +import type { RouterOutputs } from "@acme/api"; +import { cn } from "@acme/ui"; +import { Button } from "@acme/ui/button"; +import { Calendar } from "@acme/ui/calendar"; +import { Dialog, DialogContent, DialogTrigger } from "@acme/ui/dialog"; +import { Form, FormControl, FormField, FormItem, useForm } from "@acme/ui/form"; +import { Popover, PopoverContent, PopoverTrigger } from "@acme/ui/popover"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@acme/ui/tooltip"; +import { CreateTimeRecordSchema } from "@acme/validators"; + +import type { Dayjs } from "~/utils/dayjs"; +import { useConfirmClick } from "~/hooks/use-confirm-click"; +import { api } from "~/trpc/react"; +import dayjs, { displayTime } from "~/utils/dayjs"; +import { Import } from "./_components/import"; + +type TimeRecord = RouterOutputs["timeRecord"]["all"][number]; + +const TimeCell: React.FC<{ timeRecord: TimeRecord }> = ({ timeRecord }) => { + const utils = api.useUtils(); + const deleteTimeRecord = api.timeRecord.delete.useMutation({ + onSuccess: async () => { + await utils.timeRecord.all.refetch(); + }, + }); + + const { handleClick, textToShow, isConfirm } = useConfirmClick({ + text: displayTime({ date: timeRecord.time }), + confirmText: "Apagar", + onConfirm: () => { + deleteTimeRecord.mutate(timeRecord.id); + }, + }); + + return ( + + ); +}; + +const DayRow: React.FC<{ + day: string; + timeRecords: TimeRecord[]; + dailyWorkload: number; +}> = ({ day, timeRecords, dailyWorkload }) => { + const cells = useMemo(() => { + const cells = []; + for (let i = 0; i < Math.max(6, timeRecords.length); i++) { + cells.push(timeRecords[i]); + } + return cells; + }, [timeRecords]); + + const { totalTime, balanceTime, hasDebit } = useMemo(() => { + if (timeRecords.length % 2 !== 0) return {}; + + const balance = timeRecords.reduce((acc, timeRecord, i) => { + const time = dayjs(timeRecord.time); + + if (i % 2 === 0) { + return acc - time.valueOf(); + } else { + return acc + time.valueOf(); + } + }, 0); + + return { + totalTime: dayjs().startOf("day").add(balance, "ms"), + balanceTime: dayjs() + .startOf("day") + .add(Math.abs(balance - dailyWorkload * 60 * 60 * 1000)), + hasDebit: balance < dailyWorkload * 60 * 60 * 1000, + }; + }, [timeRecords, dailyWorkload]); + + return ( +
+

{day.padStart(2, "0")}

+ +
+ {cells.map((timeRecord, i) => ( +
+ {timeRecord ? : "--"} +
+ ))} +
+ + {balanceTime && ( +
+ {displayTime({ date: totalTime })} +
+ )} + + {balanceTime && ( +
+ {displayTime({ date: balanceTime })} +
+ )} +
+ ); +}; + +const AddTime: React.FC<{ teamId: string; date: Dayjs }> = ({ + teamId, + date, +}) => { + const utils = api.useUtils(); + + const createTimeRecord = api.timeRecord.create.useMutation({ + async onSuccess() { + await utils.timeRecord.all.refetch(); + }, + }); + + const form = useForm({ + schema: CreateTimeRecordSchema, + defaultValues: { + teamId, + time: date.startOf("month").toDate(), + }, + }); + + return ( + + { + createTimeRecord.mutate(data); + })} + > + ( + + + + + + + + + + + + + )} + /> + + + + ); +}; + +export default function Page({ + params: { teamId, year, month }, +}: { + params: { teamId: string; year: string; month: string }; +}) { + const date = dayjs() + .month(+month - 1) + .year(+year); + + const prev = date.subtract(1, "month"); + const next = date.add(1, "month"); + + const { data: timeRecords } = api.timeRecord.all.useQuery({ + start: date.startOf("month").toDate(), + end: date.endOf("month").toDate(), + teamId, + }); + + const { data: teamMember } = api.teamMember.get.useQuery({ teamId }); + + const groupByDay = useMemo( + () => + timeRecords?.reduce( + (acc, timeRecord) => { + const day = dayjs(timeRecord.time).date(); + if (!acc[day]) acc[day] = []; + acc[day]?.push(timeRecord); + return acc; + }, + {} as Record, + ) ?? {}, + [timeRecords], + ); + + return ( + <> +
+
+ + +
+ {date.format("MMMM [de] YYYY")} +
+
+
+ + + + + + + + + + Importar planilha + + + + + + +
+
+ +
+
+
DIA
+
PONTOS
+
TOTAL
+
SALDO
+
+ {Object.entries(groupByDay).map(([day, times]) => ( + + ))} +
+ + ); +} diff --git a/apps/nextjs/src/app/team/[teamId]/@tabs/info/page.tsx b/apps/nextjs/src/app/team/[teamId]/@tabs/info/page.tsx new file mode 100644 index 0000000..2d415a9 --- /dev/null +++ b/apps/nextjs/src/app/team/[teamId]/@tabs/info/page.tsx @@ -0,0 +1,20 @@ +import { redirect } from "next/navigation"; + +import { api } from "~/trpc/server"; +import { UserInfo } from "../../_components/user-info"; + +export default async function Page({ + params: { teamId }, +}: { + params: { teamId: string }; +}) { + const teamMember = await api.teamMember.get({ teamId }); + + if (!teamMember) { + redirect(`/team/${teamId}`); + } + + const history = await api.timeRecord.history({ teamId }); + + return ; +} diff --git a/apps/nextjs/src/app/team/[teamId]/@tabs/page.tsx b/apps/nextjs/src/app/team/[teamId]/@tabs/page.tsx new file mode 100644 index 0000000..92a9f88 --- /dev/null +++ b/apps/nextjs/src/app/team/[teamId]/@tabs/page.tsx @@ -0,0 +1,29 @@ +import { Suspense } from "react"; + +import { api } from "~/trpc/server"; +import { displayTime } from "~/utils/dayjs"; +import { Clock } from "../_components/clock"; +import { MarkTime } from "../_components/mark-time"; +import { RegisteredTimes } from "../_components/registered-times"; + +export const revalidate = 0; + +export default async function Page({ params }: { params: { teamId: string } }) { + const team = await api.team.get(params.teamId); + + if (!team) { + return
Time não encontrado
; + } + + return ( + <> + + + Loading...} + > + + + + ); +} diff --git a/apps/nextjs/src/app/team/[teamId]/_components/clock.tsx b/apps/nextjs/src/app/team/[teamId]/_components/clock.tsx new file mode 100644 index 0000000..4e1d760 --- /dev/null +++ b/apps/nextjs/src/app/team/[teamId]/_components/clock.tsx @@ -0,0 +1,9 @@ +"use client"; + +import { useClock } from "~/hooks/use-clock"; + +export function Clock({ initialTime }: { initialTime?: string }) { + const time = useClock({ initialTime }); + + return
{time}
; +} diff --git a/apps/nextjs/src/app/team/[teamId]/_components/mark-time.tsx b/apps/nextjs/src/app/team/[teamId]/_components/mark-time.tsx new file mode 100644 index 0000000..728d630 --- /dev/null +++ b/apps/nextjs/src/app/team/[teamId]/_components/mark-time.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { Button } from "@acme/ui/button"; + +import { useConfirmClick } from "~/hooks/use-confirm-click"; +import { api } from "~/trpc/react"; + +export function MarkTime({ teamId }: { teamId: string }) { + const utils = api.useUtils(); + + const createTimeRecord = api.timeRecord.create.useMutation({ + async onSuccess() { + await utils.timeRecord.all.refetch(); + }, + }); + + const { handleClick, textToShow } = useConfirmClick({ + text: "Marcar ponto", + onConfirm: () => { + createTimeRecord.mutate({ teamId }); + }, + }); + + return ( + <> + + + ); +} diff --git a/apps/nextjs/src/app/team/[teamId]/_components/registered-times.tsx b/apps/nextjs/src/app/team/[teamId]/_components/registered-times.tsx new file mode 100644 index 0000000..8ba31dd --- /dev/null +++ b/apps/nextjs/src/app/team/[teamId]/_components/registered-times.tsx @@ -0,0 +1,34 @@ +"use client"; + +import { api } from "~/trpc/react"; +import dayjs, { displayTime } from "~/utils/dayjs"; + +export function RegisteredTimes({ teamId }: { teamId: string }) { + const [timeRecords] = api.timeRecord.all.useSuspenseQuery({ + start: dayjs().startOf("day").toDate(), + end: dayjs().endOf("day").toDate(), + teamId, + }); + + if (!timeRecords.length) { + return ( +

+ Nenhum ponto registrado hoje +

+ ); + } + + return ( +
+

Pontos registrados

+
+ {timeRecords.map((timeRecord) => ( +
+
+
{displayTime({ date: timeRecord.time })}
+
+ ))} +
+
+ ); +} diff --git a/apps/nextjs/src/app/team/[teamId]/_components/user-info.tsx b/apps/nextjs/src/app/team/[teamId]/_components/user-info.tsx new file mode 100644 index 0000000..2138bf3 --- /dev/null +++ b/apps/nextjs/src/app/team/[teamId]/_components/user-info.tsx @@ -0,0 +1,74 @@ +import type { RouterOutputs } from "@acme/api"; + +import dayjs, { displayTime } from "~/utils/dayjs"; + +type TeamMember = RouterOutputs["teamMember"]["get"]; +type History = RouterOutputs["timeRecord"]["history"]; + +export function UserInfo({ + teamMember, + history, +}: { + teamMember: TeamMember; + history: History; +}) { + if (!teamMember) { + return null; + } + + return ( + <> +
+
+
Nome
+
+
{teamMember.name}
+
+ +
+
Email
+
+
{teamMember.email}
+
+ +
+
Cargo
+
+
{teamMember.role}
+
+ +
+
Entrada
+
+
+ {displayTime({ + date: dayjs(teamMember.createdAt), + format: "DD/MM/YYYY", + })} +
+
+
+ +

Relatório

+ +
+
+
MÊS
+
SALDO
+
ACUMULADO
+
+ {history.map((month, i) => ( +
+
{month.label}
+
+ {(month.balance / 1000 / 60 / 60).toFixed(1)} horas +
+
+ {(month.accumulatedBalance / 1000 / 60 / 60).toFixed(1)} horas +
+
+ ))} +
+ + ); +} diff --git a/apps/nextjs/src/app/team/[teamId]/_components/view-tabs.tsx b/apps/nextjs/src/app/team/[teamId]/_components/view-tabs.tsx new file mode 100644 index 0000000..18e763d --- /dev/null +++ b/apps/nextjs/src/app/team/[teamId]/_components/view-tabs.tsx @@ -0,0 +1,33 @@ +"use client"; + +import Link from "next/link"; +import { useSelectedLayoutSegments } from "next/navigation"; + +import { Tabs, TabsList, TabsTrigger } from "@acme/ui/tabs"; + +import dayjs from "~/utils/dayjs"; + +export function ViewTabs({ teamId }: { teamId: string }) { + const [_, tabSelected] = useSelectedLayoutSegments("tabs"); + const now = dayjs(); + + return ( + + + + Info + + + Hoje + + + + Histórico + + + + + ); +} diff --git a/apps/nextjs/src/app/team/[teamId]/layout.tsx b/apps/nextjs/src/app/team/[teamId]/layout.tsx new file mode 100644 index 0000000..c3c83a6 --- /dev/null +++ b/apps/nextjs/src/app/team/[teamId]/layout.tsx @@ -0,0 +1,43 @@ +import Link from "next/link"; + +import { api } from "~/trpc/server"; +import { ViewTabs } from "./_components/view-tabs"; + +export const revalidate = 0; + +export default async function Layout({ + params, + tabs, + admin, +}: { + params: { teamId: string }; + tabs: React.ReactNode; + admin: React.ReactNode; +}) { + const team = await api.team.get(params.teamId); + + if (!team) { + return
Time não encontrado
; + } + + return ( +
+
+ +

+ {team.name} +

+ + + {team.role === "ADMIN" ? ( + admin + ) : ( + <> + + {tabs} + + )} +
+
+ ); +} diff --git a/apps/nextjs/src/app/team/join/page.tsx b/apps/nextjs/src/app/team/join/page.tsx new file mode 100644 index 0000000..c94c59f --- /dev/null +++ b/apps/nextjs/src/app/team/join/page.tsx @@ -0,0 +1,115 @@ +"use client"; + +import { useRouter } from "next/navigation"; + +import { Button } from "@acme/ui/button"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + useForm, +} from "@acme/ui/form"; +import { Input } from "@acme/ui/input"; +import { toast } from "@acme/ui/toast"; +import { CreateTeamMemberSchema } from "@acme/validators"; + +import { api } from "~/trpc/react"; + +export default function Page({ + searchParams: { teamId }, +}: { + searchParams: { teamId?: string }; +}) { + const router = useRouter(); + + const form = useForm({ + schema: CreateTeamMemberSchema, + defaultValues: { + teamId: teamId ?? "", + dailyWorkload: 8, + initialBalanceInMinutes: 0, + }, + }); + + const createTeamMember = api.teamMember.create.useMutation({ + onSuccess: (team) => { + router.push(`/team/${team.id}`); + }, + onError: (err) => { + toast.error(err.message); + }, + }); + + return ( +
+
+

+ Juntar-se a um time +

+ +
+ { + createTeamMember.mutate(data); + })} + > + ( + + ID do time + + + + + + )} + /> + ( + + Carga horária (horas) + + + + + + )} + /> + ( + + Saldo inicial (minutos) + + + + + + )} + /> + + + +
+
+ ); +} diff --git a/apps/nextjs/src/app/team/new/page.tsx b/apps/nextjs/src/app/team/new/page.tsx new file mode 100644 index 0000000..dba94f2 --- /dev/null +++ b/apps/nextjs/src/app/team/new/page.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { useRouter } from "next/navigation"; + +import { Button } from "@acme/ui/button"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + useForm, +} from "@acme/ui/form"; +import { Input } from "@acme/ui/input"; +import { toast } from "@acme/ui/toast"; +import { CreateTeamSchema } from "@acme/validators"; + +import { api } from "~/trpc/react"; + +export default function Page() { + const router = useRouter(); + + const form = useForm({ + schema: CreateTeamSchema, + defaultValues: { + name: "", + }, + }); + + const createTeam = api.team.create.useMutation({ + onSuccess: (team) => { + router.push(`/team/${team.id}`); + }, + onError: (err) => { + toast.error( + err.data?.code === "UNAUTHORIZED" + ? "You must be logged in to post" + : "Failed to create post", + ); + }, + }); + + return ( +
+
+

+ Crie seu time +

+ +
+ { + createTeam.mutate(data); + })} + > + ( + + Nome do time + + + + + + )} + /> + + + +
+
+ ); +} diff --git a/apps/nextjs/src/components/button.tsx b/apps/nextjs/src/components/button.tsx deleted file mode 100644 index 1c1966d..0000000 --- a/apps/nextjs/src/components/button.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import clsx from "clsx"; - -export const defaultStyle = [ - "rounded-lg", - "bg-white/10", - "px-6", - "py-3", - "font-semibold", - "text-white", - "no-underline", - "transition", - "hover:bg-white/20", -]; - -type ButtonProps = React.ButtonHTMLAttributes; - -export const Button: React.FC = ({ - children, - className, - ...props -}) => { - return ( - - ); -}; diff --git a/apps/nextjs/src/components/default-layout.tsx b/apps/nextjs/src/components/default-layout.tsx deleted file mode 100644 index 6dc0c9b..0000000 --- a/apps/nextjs/src/components/default-layout.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import Head from "next/head"; - -import { Header } from "./header"; - -export const DefaultLayout: React.FC<{ - children: React.ReactNode; -}> = ({ children }) => { - return ( - <> - - Ponto - - - -
-
-
- {children} -
-
- - ); -}; diff --git a/apps/nextjs/src/components/header.tsx b/apps/nextjs/src/components/header.tsx deleted file mode 100644 index 5dfe9cd..0000000 --- a/apps/nextjs/src/components/header.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import Link from "next/link"; -import { useRouter } from "next/router"; -import * as DropdownMenu from "@radix-ui/react-dropdown-menu"; -import clsx from "clsx"; -import { signOut } from "next-auth/react"; -import { z } from "zod"; - -import { api } from "~/utils/api"; -import dayjs from "~/utils/dayjs"; -import { defaultStyle } from "./button"; - -export const Header = () => { - const { query, pathname, push } = useRouter(); - const teamId = z.string().cuid().optional().parse(query.teamId); - const { data: session } = api.auth.getSession.useQuery(); - const { data: team } = api.team.get.useQuery(teamId ?? "", { - enabled: !!teamId, - }); - - const isAdminPath = pathname.includes("/admin"); - - return ( -
-
    -
  • - {team ? ( - {team.name} - ) : ( - Ponto - )} -
  • - {team && session?.user && !isAdminPath && ( - <> -
  • - - Registros - -
  • -
  • - Resumo -
  • - - )} -
- {session?.user && ( -
- - - {session.user.name} - - - - void push("/team")} - > - - - void signOut()} - > - - - - - -
- )} -
- ); -}; diff --git a/apps/nextjs/src/components/text-field.tsx b/apps/nextjs/src/components/text-field.tsx deleted file mode 100644 index 2b3271f..0000000 --- a/apps/nextjs/src/components/text-field.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { useDescription, useTsController } from "@ts-react/form"; -import clsx from "clsx"; - -import { DefaultTsFormProps } from "~/utils/form"; - -type TextFieldProps = Omit< - React.InputHTMLAttributes, - keyof DefaultTsFormProps -> & - DefaultTsFormProps; - -export const TextField = ({ - className, - enumValues: _, - beforeElement: __, - afterElement: ___, - ...rest -}: TextFieldProps) => { - const { field, error } = useTsController(); - const { label, placeholder } = useDescription(); - - return ( -
- -
- { - if (rest.type === "number") { - field.onChange(Number(e.target.value)); - return; - } - - field.onChange(e.target.value); - }} - /> - {error?.errorMessage && ( - {error?.errorMessage} - )} -
- ); -}; diff --git a/apps/nextjs/src/components/time-field.tsx b/apps/nextjs/src/components/time-field.tsx deleted file mode 100644 index 4271107..0000000 --- a/apps/nextjs/src/components/time-field.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { useTsController } from "@ts-react/form"; -import clsx from "clsx"; - -import dayjs, { Dayjs } from "~/utils/dayjs"; -import { DefaultTsFormProps } from "~/utils/form"; - -type TimeFieldProps = Omit< - React.InputHTMLAttributes, - keyof DefaultTsFormProps | "type" | "min" | "max" -> & - DefaultTsFormProps & { - min?: Dayjs; - max?: Dayjs; - }; - -export const TimeField = ({ - className, - min, - max, - enumValues: _, - beforeElement: __, - afterElement: ___, - ...rest -}: TimeFieldProps) => { - const { field, error } = useTsController(); - - return ( - { - field.onChange(dayjs(e.target.value).toDate()); - }} - min={min ? min.format("YYYY-MM-DD[T]HH:mm") : undefined} - max={max ? max.format("YYYY-MM-DD[T]HH:mm") : undefined} - /> - ); -}; diff --git a/apps/nextjs/src/env.mjs b/apps/nextjs/src/env.mjs deleted file mode 100644 index f2b2f5a..0000000 --- a/apps/nextjs/src/env.mjs +++ /dev/null @@ -1,97 +0,0 @@ -import { z } from "zod"; - -/** - * Specify your server-side environment variables schema here. - * This way you can ensure the app isn't built with invalid env vars. - */ -export const server = z.object({ - DATABASE_URL: z.string().url(), - NODE_ENV: z.enum(["development", "test", "production"]), - NEXTAUTH_SECRET: - process.env.NODE_ENV === "production" - ? z.string().min(1) - : z.string().min(1).optional(), - NEXTAUTH_URL: z.preprocess( - // This makes Vercel deployments not fail if you don't set NEXTAUTH_URL - // Since NextAuth.js automatically uses the VERCEL_URL if present. - (str) => process.env.VERCEL_URL ?? str, - // VERCEL_URL doesn't include `https` so it cant be validated as a URL - process.env.VERCEL ? z.string() : z.string().url(), - ), - DISCORD_CLIENT_ID: z.string(), - DISCORD_CLIENT_SECRET: z.string(), - PLANETSCALE_SERVELESS_USERNAME: z.string(), - PLANETSCALE_SERVELESS_PASSWORD: z.string(), -}); - -/** - * Specify your client-side environment variables schema here. - * This way you can ensure the app isn't built with invalid env vars. - * To expose them to the client, prefix them with `NEXT_PUBLIC_`. - */ -export const client = z.object({ - // NEXT_PUBLIC_CLIENTVAR: z.string(), -}); - -/** - * You can't destruct `process.env` as a regular object in the Next.js - * edge runtimes (e.g. middlewares) or client-side so we need to destruct manually. - * @type {Record | keyof z.infer, string | undefined>} - */ -const processEnv = { - DATABASE_URL: process.env.DATABASE_URL, - NODE_ENV: process.env.NODE_ENV, - NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET, - NEXTAUTH_URL: process.env.NEXTAUTH_URL, - DISCORD_CLIENT_ID: process.env.DISCORD_CLIENT_ID, - DISCORD_CLIENT_SECRET: process.env.DISCORD_CLIENT_SECRET, - // NEXT_PUBLIC_CLIENTVAR: process.env.NEXT_PUBLIC_CLIENTVAR, - PLANETSCALE_SERVELESS_USERNAME: process.env.PLANETSCALE_SERVELESS_USERNAME, - PLANETSCALE_SERVELESS_PASSWORD: process.env.PLANETSCALE_SERVELESS_PASSWORD, -}; - -// Don't touch the part below -// -------------------------- - -const formatErrors = ( - /** @type {z.ZodFormattedError,string>} */ - errors, -) => - Object.entries(errors) - .map(([name, value]) => { - if (value && "_errors" in value) - return `${name}: ${value._errors.join(", ")}\n`; - }) - .filter(Boolean); - -const isServer = typeof window === "undefined"; - -const merged = server.merge(client); -const parsed = isServer - ? merged.safeParse(processEnv) // on server we can validate all env vars - : client.safeParse(processEnv); // on client we can only validate the ones that are exposed - -if (parsed.success === false) { - console.error( - "❌ Invalid environment variables:\n", - ...formatErrors(parsed.error.format()), - ); - throw new Error("Invalid environment variables"); -} - -/** @type z.infer - * @ts-ignore - can't type this properly in jsdoc */ -export const env = new Proxy(parsed.data, { - get(target, prop) { - if (typeof prop !== "string") return undefined; - // Throw a descriptive error if a server-side env var is accessed on the client - // Otherwise it would just be returning `undefined` and be annoying to debug - if (!isServer && !prop.startsWith("NEXT_PUBLIC_")) - throw new Error( - `❌ Attempted to access server-side environment variable '${prop}' on the client`, - ); - - // @ts-ignore - can't type this properly in jsdoc - return target[prop]; - }, -}); diff --git a/apps/nextjs/src/env.ts b/apps/nextjs/src/env.ts new file mode 100644 index 0000000..124a6cb --- /dev/null +++ b/apps/nextjs/src/env.ts @@ -0,0 +1,45 @@ +/* eslint-disable no-restricted-properties */ +import { createEnv } from "@t3-oss/env-nextjs"; +import { vercel } from "@t3-oss/env-nextjs/presets"; +import { z } from "zod"; + +import { env as authEnv } from "@acme/auth/env"; + +export const env = createEnv({ + extends: [authEnv, vercel()], + shared: { + NODE_ENV: z + .enum(["development", "production", "test"]) + .default("development"), + }, + /** + * Specify your server-side environment variables schema here. + * This way you can ensure the app isn't built with invalid env vars. + */ + server: { + DB_HOST: z.string(), + DB_NAME: z.string(), + DB_PASSWORD: z.string(), + DB_USERNAME: z.string(), + }, + + /** + * Specify your client-side environment variables schema here. + * For them to be exposed to the client, prefix them with `NEXT_PUBLIC_`. + */ + client: { + // NEXT_PUBLIC_CLIENTVAR: z.string(), + }, + /** + * Destructure all variables from `process.env` to make sure they aren't tree-shaken away. + */ + experimental__runtimeEnv: { + NODE_ENV: process.env.NODE_ENV, + + // NEXT_PUBLIC_CLIENTVAR: process.env.NEXT_PUBLIC_CLIENTVAR, + }, + skipValidation: + !!process.env.CI || + !!process.env.SKIP_ENV_VALIDATION || + process.env.npm_lifecycle_event === "lint", +}); diff --git a/apps/nextjs/src/middleware.ts b/apps/nextjs/src/middleware.ts index eeaa091..a2c8032 100644 --- a/apps/nextjs/src/middleware.ts +++ b/apps/nextjs/src/middleware.ts @@ -1,56 +1,11 @@ -import { NextResponse } from "next/server"; -import { withAuth } from "next-auth/middleware"; -import { z } from "zod"; -import { connectEdge } from "@acme/db/utils/kysely"; +export { auth as middleware } from "@acme/auth"; -export default withAuth( - async function middleware({ nextUrl, nextauth, url }) { - if (!nextauth.token) { - return NextResponse.redirect(new URL("/", url)); - } - - if (nextUrl.pathname.startsWith("/team")) { - const [_, __, teamId, adminPath] = nextUrl.pathname.split("/"); - - if (!teamId || teamId === "new" || teamId === "join") { - return NextResponse.next(); - } - - const safeTeamId = z.string().cuid().safeParse(teamId); - - if (!safeTeamId.success) { - return NextResponse.redirect(new URL("/team", url)); - } - - const team = await connectEdge() - .selectFrom("TeamMember") - .select("role") - .where("userId", "=", nextauth.token.user.id) - .where("teamId", "=", safeTeamId.data) - .executeTakeFirst(); - - if (!team) { - return NextResponse.redirect(new URL("/team", url)); - } - - if (team.role === "ADMIN" && adminPath !== "admin") { - return NextResponse.redirect( - new URL(`/team/${safeTeamId.data}/admin`, url), - ); - } - - if (team.role !== "ADMIN" && adminPath === "admin") { - return NextResponse.redirect(new URL(`/team/${safeTeamId.data}`, url)); - } - } - }, - { - callbacks: { - authorized: ({ token }) => !!token, - }, - }, -); +// Or like this if you need to do something here. +// export default auth((req) => { +// console.log(req.auth) // { session: { user: { ... } } } +// }) +// Read more: https://nextjs.org/docs/app/building-your-application/routing/middleware#matcher export const config = { - matcher: ["/team", "/team/(new|join)", "/team/:teamId*"], + matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"], }; diff --git a/apps/nextjs/src/pages/_app.tsx b/apps/nextjs/src/pages/_app.tsx deleted file mode 100644 index 3e63166..0000000 --- a/apps/nextjs/src/pages/_app.tsx +++ /dev/null @@ -1,30 +0,0 @@ -// src/pages/_app.tsx -import "../styles/globals.css"; -import type { AppType } from "next/app"; -import { Analytics } from "@vercel/analytics/react"; -import type { Session } from "next-auth"; -import { SessionProvider } from "next-auth/react"; -import NextNProgress from "nextjs-progressbar"; - -import { api } from "~/utils/api"; -import { DefaultLayout } from "~/components/default-layout"; -import "~/utils/dayjs"; - -const MyApp: AppType<{ session: Session | null }> = ({ - Component, - pageProps: { session, ...pageProps }, -}) => { - return ( - <> - - - - - - - - - ); -}; - -export default api.withTRPC(MyApp); diff --git a/apps/nextjs/src/pages/_document.tsx b/apps/nextjs/src/pages/_document.tsx deleted file mode 100644 index a7cbbea..0000000 --- a/apps/nextjs/src/pages/_document.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { Head, Html, Main, NextScript } from "next/document"; - -const Document = () => { - return ( - - - -
- - - - ); -}; - -export default Document; diff --git a/apps/nextjs/src/pages/api/auth/[...nextauth].ts b/apps/nextjs/src/pages/api/auth/[...nextauth].ts deleted file mode 100644 index 077f6c3..0000000 --- a/apps/nextjs/src/pages/api/auth/[...nextauth].ts +++ /dev/null @@ -1,4 +0,0 @@ -import NextAuth from "next-auth"; -import { authOptions } from "@acme/auth"; - -export default NextAuth(authOptions); diff --git a/apps/nextjs/src/pages/api/trpc/[trpc].ts b/apps/nextjs/src/pages/api/trpc/[trpc].ts deleted file mode 100644 index df4e770..0000000 --- a/apps/nextjs/src/pages/api/trpc/[trpc].ts +++ /dev/null @@ -1,22 +0,0 @@ -import { createNextApiHandler } from "@trpc/server/adapters/next"; -import { appRouter, createTRPCContext } from "@acme/api"; - -// export API handler -export default createNextApiHandler({ - router: appRouter, - createContext: createTRPCContext, -}); - -// If you need to enable cors, you can do so like this: -// const handler = async (req: NextApiRequest, res: NextApiResponse) => { -// // Enable cors -// await cors(req, res); - -// // Let the tRPC handler do its magic -// return createNextApiHandler({ -// router: appRouter, -// createContext, -// })(req, res); -// }; - -// export default handler; diff --git a/apps/nextjs/src/pages/sign-in.tsx b/apps/nextjs/src/pages/sign-in.tsx deleted file mode 100644 index d607548..0000000 --- a/apps/nextjs/src/pages/sign-in.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import type { - GetServerSideProps, - InferGetServerSidePropsType, - NextPage, -} from "next"; -import { getProviders, signIn } from "next-auth/react"; - -import { Button } from "~/components/button"; - -export const getServerSideProps: GetServerSideProps<{ - providers: Awaited>; -}> = async (_) => { - const providers = await getProviders(); - return { - props: { - providers, - }, - }; -}; - -const SignIn: NextPage< - InferGetServerSidePropsType -> = ({ providers }) => { - return ( - <> - {Object.values(providers ?? {}).map((provider) => ( -
-
- -
- ))} - - ); -}; - -export default SignIn; diff --git a/apps/nextjs/src/pages/team/[teamId]/[year]/[month]/index.tsx b/apps/nextjs/src/pages/team/[teamId]/[year]/[month]/index.tsx deleted file mode 100644 index 6e57415..0000000 --- a/apps/nextjs/src/pages/team/[teamId]/[year]/[month]/index.tsx +++ /dev/null @@ -1,275 +0,0 @@ -import { ReactNode, useMemo } from "react"; -import { InferGetServerSidePropsType, NextPage } from "next"; -import Link from "next/link"; -import clsx from "clsx"; -import { z } from "zod"; - -import { RouterOutputs, api } from "~/utils/api"; -import dayjs, { Dayjs, dayjsLib, displayTime } from "~/utils/dayjs"; -import { createMyForm } from "~/utils/form"; -import { createSSR } from "~/utils/ssr"; -import { defaultStyle } from "~/components/button"; -import { useConfirmClick } from "~/hooks/use-confirm-click"; - -type TimeRecord = RouterOutputs["timeRecord"]["all"][number]; - -const TimeCell: React.FC<{ timeRecord: TimeRecord }> = ({ timeRecord }) => { - const utils = api.useContext(); - const { mutate: deleteTime } = api.timeRecord.delete.useMutation({ - onSuccess: async () => { - await utils.timeRecord.all.refetch(); - }, - }); - - const { handleClick, textToShow, isConfirm } = useConfirmClick({ - text: displayTime({ date: timeRecord.time }), - confirmText: "Apagar", - onConfirm: () => { - deleteTime(timeRecord.id); - }, - }); - - return ( - <> - - - ); -}; - -const DayRow: React.FC<{ - day: string; - timeRecords: TimeRecord[]; - dailyWorkload: number; -}> = ({ day, timeRecords, dailyWorkload }) => { - const cells = useMemo(() => { - const cells = []; - for (let i = 0; i < Math.max(6, timeRecords.length); i++) { - cells.push(timeRecords[i]); - } - return cells; - }, [timeRecords]); - - const { totalTime, balanceTime, hasDebit } = useMemo(() => { - if (timeRecords.length % 2 !== 0) return {}; - - const balance = timeRecords.reduce((acc, timeRecord, i) => { - const time = dayjs(timeRecord.time); - - if (i % 2 === 0) { - return acc - time.valueOf(); - } else { - return acc + time.valueOf(); - } - }, 0); - - return { - totalTime: dayjs().startOf("day").add(balance, "ms"), - balanceTime: dayjs() - .startOf("day") - .add(Math.abs(balance - dailyWorkload * 60 * 60 * 1000)), - hasDebit: balance < dailyWorkload * 60 * 60 * 1000, - }; - }, [timeRecords, dailyWorkload]); - - return ( -
-

{day.padStart(2, "0")}

-
- {cells.map((timeRecord, i) => ( -
- {timeRecord ? : "--"} -
- ))} -
- {balanceTime && ( -
- {displayTime({ date: totalTime })} -
- )} -
- {balanceTime && ( -
- {displayTime({ date: balanceTime })} -
- )} -
- ); -}; - -const AddTimeSchema = z.object({ - time: z.date(), -}); - -const AddTimeForm = ({ - children, - onSubmit, -}: { - children: ReactNode; - onSubmit: () => void; -}) => { - const { handleClick, textToShow } = useConfirmClick({ - text: "Adicionar", - onConfirm: onSubmit, - }); - - return ( -
- {children} - -
- ); -}; - -const AddTime: React.FC<{ teamId: string; date: Dayjs }> = ({ - teamId, - date, -}) => { - const utils = api.useContext(); - - const { mutate: markTime } = api.timeRecord.create.useMutation({ - async onSuccess() { - await utils.timeRecord.all.refetch(); - }, - }); - - const onSubmit = (values: z.infer) => { - markTime({ teamId, time: values.time }); - }; - - const MyForm = createMyForm(AddTimeForm); - - return ( - - ); -}; - -export const getServerSideProps = createSSR( - z.object({ - teamId: z.string().cuid(), - year: z.coerce.number().min(2000), - month: z.coerce.number().min(1).max(12), - }), - async (ssr, { teamId, year, month }) => { - const date = dayjs() - .month(month - 1) - .year(year); - - await ssr.timeRecord.all.prefetch({ - start: date.startOf("month").toDate(), - end: date.endOf("month").toDate(), - teamId, - }); - - await ssr.teamMember.get.prefetch({ teamId }); - }, -); - -const Registros: NextPage< - InferGetServerSidePropsType -> = ({ teamId, year, month }) => { - const date = dayjs() - .month(month - 1) - .year(year); - - const { data: timeRecords } = api.timeRecord.all.useQuery({ - start: date.startOf("month").toDate(), - end: date.endOf("month").toDate(), - teamId, - }); - - const { data: teamMember } = api.teamMember.get.useQuery({ teamId }); - - const groupByDay = useMemo( - () => - timeRecords?.reduce((acc, timeRecord) => { - const day = dayjs(timeRecord.time).date(); - if (!acc[day]) acc[day] = []; - acc[day]?.push(timeRecord); - return acc; - }, {} as Record) ?? {}, - [timeRecords], - ); - - return ( - <> -

- - {"<"} - -
- {date.format("MMMM [de] YYYY")} -
- - {">"} - -

-
-
- -
- - Importar planilha Kayo - -
- -
-
-
-
DIA
-
PONTOS
-
TOTAL
-
SALDO
-
-
- {Object.entries(groupByDay).map(([day, times]) => ( - - ))} -
- - ); -}; - -export default Registros; diff --git a/apps/nextjs/src/pages/team/[teamId]/admin/[userId].tsx b/apps/nextjs/src/pages/team/[teamId]/admin/[userId].tsx deleted file mode 100644 index 208a6f3..0000000 --- a/apps/nextjs/src/pages/team/[teamId]/admin/[userId].tsx +++ /dev/null @@ -1,80 +0,0 @@ -import type { InferGetServerSidePropsType, NextPage } from "next"; -import dayjs from "dayjs"; -import { z } from "zod"; - -import { api } from "~/utils/api"; -import { displayTime } from "~/utils/dayjs"; -import { createSSR } from "~/utils/ssr"; - -export const getServerSideProps = createSSR( - z.object({ - teamId: z.string().cuid(), - userId: z.string().cuid(), - }), - async (ssr, { teamId, userId }) => { - await ssr.timeRecord.history.prefetch({ teamId, userId }); - await ssr.teamMember.get.prefetch({ teamId, userId }); - }, -); - -const MemberTeam: NextPage< - InferGetServerSidePropsType -> = ({ teamId, userId }) => { - const { data: teamMember } = api.teamMember.get.useQuery({ teamId, userId }); - const { data: history } = api.timeRecord.history.useQuery({ teamId, userId }); - - return ( - <> -
Informações
-
-
-
-
Nome
-
{teamMember?.user.name}
-
-
-
Email
-
{teamMember?.user.email}
-
-
-
Cargo
-
{teamMember?.role}
-
-
-
Entrada
-
- {displayTime({ - date: dayjs(teamMember?.createdAt), - format: "DD/MM/YYYY", - })} -
-
-
-
- -
Histórico
-
-
-
-
MÊS
-
SALDO
-
ACUMULADO
-
- {history?.map((month, i) => ( -
-
{month.label}
-
- {(month.balance / 1000 / 60 / 60).toFixed(1)} horas -
-
- {(month.accumulatedBalance / 1000 / 60 / 60).toFixed(1)} horas -
-
- ))} -
-
- - ); -}; - -export default MemberTeam; diff --git a/apps/nextjs/src/pages/team/[teamId]/admin/index.tsx b/apps/nextjs/src/pages/team/[teamId]/admin/index.tsx deleted file mode 100644 index 8bbe2bc..0000000 --- a/apps/nextjs/src/pages/team/[teamId]/admin/index.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import type { InferGetServerSidePropsType, NextPage } from "next"; -import Link from "next/link"; -import clsx from "clsx"; -import { z } from "zod"; - -import { api } from "~/utils/api"; -import { createSSR } from "~/utils/ssr"; -import { defaultStyle } from "~/components/button"; -import { CopyText } from "~/components/copy-text"; - -export const getServerSideProps = createSSR( - z.object({ - teamId: z.coerce.string().cuid(), - }), - async (ssr, { teamId }, req) => { - await ssr.teamMember.all.prefetch(teamId); - - return { - result: "success", - data: { - baseUrl: req.headers.host ?? "", - }, - }; - }, -); - -const TeamAdmin: NextPage< - InferGetServerSidePropsType -> = ({ teamId, baseUrl }) => { - const { data: members } = api.teamMember.all.useQuery(teamId); - - return ( - <> -

Convide novos membros

-
- -
-

Membros

-
-
- {members - ?.filter((member) => member.role != "ADMIN") - .map((member) => ( -
- {member.user.name} - - Visualizar - -
- ))} -
- - ); -}; - -export default TeamAdmin; diff --git a/apps/nextjs/src/pages/team/[teamId]/index.tsx b/apps/nextjs/src/pages/team/[teamId]/index.tsx deleted file mode 100644 index b7cf7c6..0000000 --- a/apps/nextjs/src/pages/team/[teamId]/index.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import type { InferGetServerSidePropsType, NextPage } from "next"; -import { z } from "zod"; - -import { api } from "~/utils/api"; -import dayjs, { displayTime } from "~/utils/dayjs"; -import { createSSR } from "~/utils/ssr"; -import { Button } from "~/components/button"; -import { useClock } from "~/hooks/use-clock"; -import { useConfirmClick } from "~/hooks/use-confirm-click"; - -const Clock = ({ initialTime }: { initialTime?: string }) => { - const time = useClock({ initialTime }); - - return
{time}
; -}; - -const MarkTimeButton = ({ teamId }: { teamId: string }) => { - const utils = api.useContext(); - const { mutate: markTime, isLoading } = api.timeRecord.create.useMutation({ - async onSuccess() { - await utils.timeRecord.all.refetch(); - }, - }); - - const { handleClick, textToShow } = useConfirmClick({ - text: "Marcar ponto", - onConfirm: () => { - markTime({ teamId }); - }, - }); - - return ( - <> - - - ); -}; - -const RegisteredTimes = ({ teamId }: { teamId: string }) => { - const { data: timeRecords } = api.timeRecord.all.useQuery({ - start: dayjs().startOf("day").toDate(), - end: dayjs().endOf("day").toDate(), - teamId, - }); - - if (!timeRecords || timeRecords.length == 0) - return ( -

Nenhum ponto registrado hoje

- ); - - return ( -
-
-

Pontos registrados

-
-
- {timeRecords?.map((timeRecord) => ( -
-
-
{displayTime({ date: timeRecord.time })}
-
- ))} -
-
- ); -}; - -export const getServerSideProps = createSSR( - z.object({ - teamId: z.coerce.string().cuid(), - }), - async (ssr, { teamId }) => { - await ssr.timeRecord.all.prefetch({ - start: dayjs().startOf("day").toDate(), - end: dayjs().endOf("day").toDate(), - teamId, - }); - - return { - result: "success", - data: { - clock: displayTime({ format: "HH:mm:ss" }), - }, - }; - }, -); - -const Team: NextPage< - InferGetServerSidePropsType -> = ({ clock, teamId }) => { - return ( - <> - -
- - - - ); -}; - -export default Team; diff --git a/apps/nextjs/src/pages/team/[teamId]/info.tsx b/apps/nextjs/src/pages/team/[teamId]/info.tsx deleted file mode 100644 index 4b27cbb..0000000 --- a/apps/nextjs/src/pages/team/[teamId]/info.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import type { InferGetServerSidePropsType, NextPage } from "next"; -import { z } from "zod"; - -import { api } from "~/utils/api"; -import { MyForm } from "~/utils/form"; -import { createSSR } from "~/utils/ssr"; -import { Button } from "~/components/button"; - -const InfoTeamSchema = z.object({ - dailyWorkload: z - .number() - .describe("Carga horária (horas) // Digite a carga horária"), - initialBalanceInMinutes: z - .number() - .describe("Saldo inicial (minutos) // Saldo inicial em minutos"), -}); - -export const getServerSideProps = createSSR( - z.object({ - teamId: z.coerce.string().cuid(), - }), - async (ssr, { teamId }) => { - await ssr.teamMember.get.prefetch({ teamId }); - await ssr.timeRecord.history.prefetch({ teamId }); - }, -); - -const InfoTeam: NextPage< - InferGetServerSidePropsType -> = ({ teamId }) => { - const utils = api.useContext(); - const { data: teamMember } = api.teamMember.get.useQuery({ teamId }); - const { data: history } = api.timeRecord.history.useQuery({ teamId }); - const { mutate, isLoading } = api.teamMember.update.useMutation({ - onSuccess: async () => { - await utils.teamMember.get.refetch({ teamId }); - await utils.timeRecord.history.refetch({ teamId }); - }, - }); - - const onSubmit = (values: z.infer) => { - if (isLoading) return; - - if ( - teamMember?.dailyWorkload === values.dailyWorkload && - teamMember?.initialBalanceInMinutes == values.initialBalanceInMinutes - ) - return; - - mutate({ ...values, teamId: teamId }); - }; - - return ( - <> -
Histórico
-
-
-
-
MÊS
-
SALDO
-
ACUMULADO
-
- {history?.map((month, i) => ( -
-
{month.label}
-
- {(month.balance / 1000 / 60 / 60).toFixed(1)} horas -
-
- {(month.accumulatedBalance / 1000 / 60 / 60).toFixed(1)} horas -
-
- ))} -
-
- -
Configurações
-
- ( -
- -
- )} - defaultValues={{ - dailyWorkload: teamMember?.dailyWorkload, - initialBalanceInMinutes: teamMember?.initialBalanceInMinutes, - }} - props={{ - dailyWorkload: { - type: "number", - }, - initialBalanceInMinutes: { - type: "number", - beforeElement:
, - }, - }} - /> - - ); -}; - -export default InfoTeam; diff --git a/apps/nextjs/src/pages/team/index.tsx b/apps/nextjs/src/pages/team/index.tsx deleted file mode 100644 index 63f94e1..0000000 --- a/apps/nextjs/src/pages/team/index.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import type { NextPage } from "next"; -import Link from "next/link"; -import clsx from "clsx"; -import { z } from "zod"; - -import { api } from "~/utils/api"; -import { createSSR } from "~/utils/ssr"; -import { defaultStyle } from "~/components/button"; -import { Team } from ".prisma/client"; - -const CardTeam: React.FC<{ team: Team }> = ({ team }) => { - return ( - - {team.name} - - ); -}; - -export const getServerSideProps = createSSR(z.object({}), async (ssr, _) => { - await ssr.team.all.prefetch(); -}); - -const Teams: NextPage = () => { - const { data: teams } = api.team.all.useQuery(); - - return ( - <> - {!teams?.length ? ( -
- Você não tem nenhum time -
- ) : ( - <> -
-

Selecione seu time

-
- -
- {teams?.map(({ team }) => ( - - ))} -
- - )} -
- - Criar time - -
- - Juntar-se a um time - - - ); -}; - -export default Teams; diff --git a/apps/nextjs/src/pages/team/join.tsx b/apps/nextjs/src/pages/team/join.tsx deleted file mode 100644 index 05cfabf..0000000 --- a/apps/nextjs/src/pages/team/join.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import type { InferGetServerSidePropsType, NextPage } from "next"; -import { useRouter } from "next/router"; -import { z } from "zod"; - -import { api } from "~/utils/api"; -import { MyForm } from "~/utils/form"; -import { createSSR } from "~/utils/ssr"; -import { Button } from "~/components/button"; - -export const getServerSideProps = createSSR( - z.object({ - teamId: z.string().cuid().optional(), - }), - async (_, { teamId }) => { - if (!teamId) return; - - return { - result: "success", - data: { - teamId, - }, - }; - }, -); - -const JoinTeamSchema = z.object({ - teamId: z.string().describe("ID do time // ID do time"), - dailyWorkload: z - .number() - .describe("Carga horária (horas) // Carga horária diária"), - initialBalanceInMinutes: z - .number() - .describe("Saldo inicial (minutos) // Saldo inicial em minutos"), -}); - -const JoinTeam: NextPage< - InferGetServerSidePropsType -> = ({ teamId }) => { - const router = useRouter(); - const { mutate, isLoading } = api.teamMember.create.useMutation({ - onSuccess: async (team) => { - await router.push(`/team/${team.id}`); - }, - }); - - const onSubmit = (values: z.infer) => { - if (isLoading) return; - - mutate(values); - }; - - return ( - <> -
-

Juntar-se a um time

-
- - ( -
- -
- )} - defaultValues={{ - teamId, - initialBalanceInMinutes: 0, - }} - props={{ - dailyWorkload: { - type: "number", - beforeElement:
, - }, - initialBalanceInMinutes: { - type: "number", - beforeElement:
, - }, - }} - /> - - ); -}; - -export default JoinTeam; diff --git a/apps/nextjs/src/pages/team/new.tsx b/apps/nextjs/src/pages/team/new.tsx deleted file mode 100644 index 9f18dcc..0000000 --- a/apps/nextjs/src/pages/team/new.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import type { NextPage } from "next"; -import { useRouter } from "next/router"; -import { z } from "zod"; - -import { api } from "~/utils/api"; -import { MyForm } from "~/utils/form"; -import { Button } from "~/components/button"; - -const NewTeamSchema = z.object({ - name: z.string(), -}); - -const NewTeam: NextPage = () => { - const router = useRouter(); - const { mutate, isLoading } = api.team.create.useMutation({ - onSuccess: async (team) => { - await router.push(`/team/${team.id}/admin`); - }, - }); - - const onSubmit = (values: z.infer) => { - if (isLoading) return; - - mutate(values); - }; - - return ( - <> -
-

Crie seu time

-
- - ( -
- -
- )} - props={{ - name: { - className: "my-2", - }, - }} - /> - - ); -}; - -export default NewTeam; diff --git a/apps/nextjs/src/styles/globals.css b/apps/nextjs/src/styles/globals.css deleted file mode 100644 index b5c61c9..0000000 --- a/apps/nextjs/src/styles/globals.css +++ /dev/null @@ -1,3 +0,0 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; diff --git a/apps/nextjs/src/trpc/react.tsx b/apps/nextjs/src/trpc/react.tsx new file mode 100644 index 0000000..b9ac6f1 --- /dev/null +++ b/apps/nextjs/src/trpc/react.tsx @@ -0,0 +1,76 @@ +"use client"; + +import { useState } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { loggerLink, unstable_httpBatchStreamLink } from "@trpc/client"; +import { createTRPCReact } from "@trpc/react-query"; +import SuperJSON from "superjson"; + +import type { AppRouter } from "@acme/api"; + +import { env } from "~/env"; + +const createQueryClient = () => + new QueryClient({ + defaultOptions: { + queries: { + // With SSR, we usually want to set some default staleTime + // above 0 to avoid refetching immediately on the client + staleTime: 30 * 1000, + }, + }, + }); + +let clientQueryClientSingleton: QueryClient | undefined = undefined; +const getQueryClient = () => { + if (typeof window === "undefined") { + // Server: always make a new query client + return createQueryClient(); + } else { + // Browser: use singleton pattern to keep the same query client + return (clientQueryClientSingleton ??= createQueryClient()); + } +}; + +export const api = createTRPCReact(); + +export function TRPCReactProvider(props: { children: React.ReactNode }) { + const queryClient = getQueryClient(); + + const [trpcClient] = useState(() => + api.createClient({ + links: [ + loggerLink({ + enabled: (op) => + env.NODE_ENV === "development" || + (op.direction === "down" && op.result instanceof Error), + }), + unstable_httpBatchStreamLink({ + transformer: SuperJSON, + url: getBaseUrl() + "/api/trpc", + headers() { + const headers = new Headers(); + headers.set("x-trpc-source", "nextjs-react"); + return headers; + }, + }), + ], + }), + ); + + return ( + + + {props.children} + + + ); +} + +export const getBaseUrl = () => { + if (typeof window !== "undefined") return window.location.origin; + if (env.VERCEL_URL) return `https://${env.VERCEL_URL}`; + + // eslint-disable-next-line no-restricted-properties + return `http://localhost:${process.env.PORT ?? 3000}`; +}; diff --git a/apps/nextjs/src/trpc/server.ts b/apps/nextjs/src/trpc/server.ts new file mode 100644 index 0000000..7ce8d34 --- /dev/null +++ b/apps/nextjs/src/trpc/server.ts @@ -0,0 +1,21 @@ +import { cache } from "react"; +import { headers } from "next/headers"; + +import { createCaller, createTRPCContext } from "@acme/api"; +import { auth } from "@acme/auth"; + +/** + * This wraps the `createTRPCContext` helper and provides the required context for the tRPC API when + * handling a tRPC call from a React Server Component. + */ +const createContext = cache(async () => { + const heads = new Headers(headers()); + heads.set("x-trpc-source", "rsc"); + + return createTRPCContext({ + session: await auth(), + headers: heads, + }); +}); + +export const api = createCaller(createContext); diff --git a/apps/nextjs/src/utils/api.ts b/apps/nextjs/src/utils/api.ts deleted file mode 100644 index 7d99c51..0000000 --- a/apps/nextjs/src/utils/api.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { httpBatchLink, loggerLink } from "@trpc/client"; -import { createTRPCNext } from "@trpc/next"; -import { inferRouterInputs, inferRouterOutputs } from "@trpc/server"; -import type { AppRouter } from "@acme/api"; -import { transformer } from "@acme/api/transformer"; - -const getBaseUrl = () => { - if (typeof window !== "undefined") return ""; // browser should use relative url - if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`; // SSR should use vercel url - - return `http://localhost:${process.env.PORT ?? 3000}`; // dev SSR should use localhost -}; - -export const api = createTRPCNext({ - config() { - return { - transformer, - links: [ - loggerLink({ - enabled: (opts) => - process.env.NODE_ENV === "development" || - (opts.direction === "down" && opts.result instanceof Error), - }), - httpBatchLink({ - url: `${getBaseUrl()}/api/trpc`, - }), - ], - queryClientConfig: { - defaultOptions: { - queries: { - refetchOnMount: false, - }, - }, - }, - }; - }, - ssr: false, -}); - -/** - * Inference helpers for input types - * @example type HelloInput = RouterInputs['example']['hello'] - **/ -export type RouterInputs = inferRouterInputs; - -/** - * Inference helpers for output types - * @example type HelloOutput = RouterOutputs['example']['hello'] - **/ -export type RouterOutputs = inferRouterOutputs; diff --git a/apps/nextjs/src/utils/dayjs.ts b/apps/nextjs/src/utils/dayjs.ts index 27f3466..2d3ea9c 100644 --- a/apps/nextjs/src/utils/dayjs.ts +++ b/apps/nextjs/src/utils/dayjs.ts @@ -1,4 +1,5 @@ -import lib, { ConfigType, Dayjs, OptionType } from "dayjs"; +import type { ConfigType, OptionType } from "dayjs"; +import lib, { Dayjs } from "dayjs"; import ptbr from "dayjs/locale/pt-br"; import minMax from "dayjs/plugin/minMax"; import timezone from "dayjs/plugin/timezone"; diff --git a/apps/nextjs/src/utils/form.ts b/apps/nextjs/src/utils/form.ts deleted file mode 100644 index 900ef8d..0000000 --- a/apps/nextjs/src/utils/form.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { ReactNode } from "react"; -import { createTsForm } from "@ts-react/form"; -import { Control, FieldValues } from "react-hook-form"; -import { z } from "zod"; - -import { TextField } from "~/components/text-field"; -import { TimeField } from "~/components/time-field"; - -export type DefaultTsFormProps = { - name: string; - control: Control; - enumValues?: string[]; - label?: string; - placeholder?: string; - beforeElement?: ReactNode; - afterElement?: ReactNode; -}; - -const mapping = [ - [z.string(), TextField], - [z.number(), TextField], - [z.date(), TimeField], -] as const; - -export const MyForm = createTsForm(mapping); -export const createMyForm = ( - FormComponent: < - T extends Record & { - children: ReactNode; - onSubmit: () => void; - }, - >( - props: T, - ) => JSX.Element, -) => createTsForm(mapping, { FormComponent }); diff --git a/apps/nextjs/src/utils/ssr.ts b/apps/nextjs/src/utils/ssr.ts deleted file mode 100644 index a92b061..0000000 --- a/apps/nextjs/src/utils/ssr.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { ParsedUrlQuery } from "querystring"; -import { GetServerSideProps, GetServerSidePropsContext } from "next"; -import { DehydratedState } from "@tanstack/react-query"; -import { createProxySSGHelpers } from "@trpc/react-query/ssg"; -import { z } from "zod"; -import { appRouter } from "@acme/api"; -import { createInnerTRPCContext } from "@acme/api/src/trpc"; -import { transformer } from "@acme/api/transformer"; -import { JWT, getServerToken } from "@acme/auth"; - -const getContext = (token: JWT | null) => { - return createProxySSGHelpers({ - router: appRouter, - ctx: createInnerTRPCContext({ token }), - transformer: transformer, - }); -}; - -type SuccessType = { - result: "success"; - data: T; -}; - -type ErrorType = { - result: "error"; -}; - -type RedirectType = { - result: "redirect"; - destination: string; - message?: string; -}; - -export const createSSR = ( - queryScheme: z.Schema, - callback: >( - ssrContext: SSRContext, - query: Q, - req: GetServerSidePropsContext["req"], - ) => Promise | RedirectType | ErrorType | void>, -) => { - const getServerSideProps: GetServerSideProps< - R & Q & { trpcState: DehydratedState }, - Q extends ParsedUrlQuery ? Q : ParsedUrlQuery - > = async (context) => { - const { req, query } = context; - - const parsedQuery = queryScheme.parse(query); - - const token = await getServerToken({ req }); - - const ssr = getContext(token); - - const callbackResult = await callback(ssr, parsedQuery, req); - - if (callbackResult?.result === "error") { - return { - notFound: true, - }; - } - - if (callbackResult?.result === "redirect") { - return { - redirect: { - destination: callbackResult.destination, - permanent: false, - }, - }; - } - - if (callbackResult?.result === "success") { - return { - props: { - ...parsedQuery, - ...callbackResult.data, - trpcState: ssr.dehydrate(), - }, - }; - } - - return { - props: { - ...({} as R), - ...parsedQuery, - trpcState: ssr.dehydrate(), - }, - }; - }; - - return getServerSideProps; -}; diff --git a/apps/nextjs/tailwind.config.cjs b/apps/nextjs/tailwind.config.cjs deleted file mode 100644 index 5ae5802..0000000 --- a/apps/nextjs/tailwind.config.cjs +++ /dev/null @@ -1,5 +0,0 @@ -/** @type {import("tailwindcss").Config} */ -module.exports = { - // @ts-ignore - presets: [require("@acme/tailwind-config")], -}; diff --git a/apps/nextjs/tailwind.config.ts b/apps/nextjs/tailwind.config.ts new file mode 100644 index 0000000..4d5d815 --- /dev/null +++ b/apps/nextjs/tailwind.config.ts @@ -0,0 +1,19 @@ +import type { Config } from "tailwindcss"; +import { fontFamily } from "tailwindcss/defaultTheme"; + +import baseConfig from "@acme/tailwind-config/web"; + +export default { + // We need to append the path to the UI package to the content array so that + // those classes are included correctly. + content: [...baseConfig.content, "../../packages/ui/**/*.{ts,tsx}"], + presets: [baseConfig], + theme: { + extend: { + fontFamily: { + sans: ["var(--font-geist-sans)", ...fontFamily.sans], + mono: ["var(--font-geist-mono)", ...fontFamily.mono], + }, + }, + }, +} satisfies Config; diff --git a/apps/nextjs/tsconfig.json b/apps/nextjs/tsconfig.json index d406c10..893effc 100644 --- a/apps/nextjs/tsconfig.json +++ b/apps/nextjs/tsconfig.json @@ -1,11 +1,21 @@ { - "extends": "../../tsconfig.json", + "extends": "@acme/tsconfig/base.json", "compilerOptions": { + "lib": ["es2022", "dom", "dom.iterable"], + "jsx": "preserve", "baseUrl": ".", "paths": { - "~/*": ["./src/*"] - } + "~/*": ["src/*"] + }, + "plugins": [ + { + "name": "next" + } + ], + "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json", + "module": "esnext", + "strictNullChecks": true }, - "exclude": [], - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "**/*.cjs", "**/*.mjs"] + "include": [".", ".next/types/**/*.ts"], + "exclude": ["node_modules"] } diff --git a/package.json b/package.json index 6cbf0ca..563d99d 100644 --- a/package.json +++ b/package.json @@ -2,40 +2,31 @@ "name": "create-t3-turbo", "private": true, "engines": { - "node": ">=18.0.0" + "node": ">=20.12.0" }, - "packageManager": "pnpm@7.27.0", + "packageManager": "pnpm@9.0.6", "scripts": { "build": "turbo build", - "clean": "rm -rf node_modules", + "clean": "git clean -xdf node_modules", "clean:workspaces": "turbo clean", - "db:generate": "turbo db:generate", - "db:push": "turbo db:push db:generate", + "db:push": "pnpm -F db push", + "db:studio": "pnpm -F db studio", "dev": "turbo dev --parallel", - "format": "prettier --write \"**/*.{ts,tsx,md}\"", - "lint": "turbo lint && manypkg check", - "type-check": "turbo type-check" + "format": "turbo format --continue -- --cache --cache-location node_modules/.cache/.prettiercache", + "format:fix": "turbo format --continue -- --write --cache --cache-location node_modules/.cache/.prettiercache", + "lint": "turbo lint --continue -- --cache --cache-location node_modules/.cache/.eslintcache", + "lint:fix": "turbo lint --continue -- --fix --cache --cache-location node_modules/.cache/.eslintcache", + "lint:ws": "pnpm dlx sherif@latest", + "postinstall": "pnpm lint:ws", + "typecheck": "turbo typecheck", + "ui-add": "pnpm -F ui ui-add" }, - "dependencies": { - "@ianvs/prettier-plugin-sort-imports": "^3.7.1", - "@manypkg/cli": "^0.20.0", - "@types/eslint": "^8.21.0", - "@typescript-eslint/eslint-plugin": "^5.49.0", - "@typescript-eslint/parser": "^5.49.0", - "eslint": "^8.33.0", - "eslint-config-prettier": "^8.6.0", - "prettier": "^2.8.3", - "prettier-plugin-tailwindcss": "^0.2.2", - "turbo": "^1.7.4", - "typescript": "^4.9.5" + "devDependencies": { + "@acme/prettier-config": "workspace:*", + "@turbo/gen": "^1.13.3", + "prettier": "^3.2.5", + "turbo": "^1.13.3", + "typescript": "^5.4.5" }, - "pnpm": { - "packageExtensions": { - "eslint-config-next@*": { - "dependencies": { - "next": "*" - } - } - } - } + "prettier": "@acme/prettier-config" } diff --git a/packages/api/eslint.config.js b/packages/api/eslint.config.js new file mode 100644 index 0000000..b87792c --- /dev/null +++ b/packages/api/eslint.config.js @@ -0,0 +1,9 @@ +import baseConfig from "@acme/eslint-config/base"; + +/** @type {import('typescript-eslint').Config} */ +export default [ + { + ignores: ["dist/**"], + }, + ...baseConfig, +]; diff --git a/packages/api/index.ts b/packages/api/index.ts deleted file mode 100644 index 888f301..0000000 --- a/packages/api/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { appRouter, type AppRouter } from "./src/root"; -export { createTRPCContext } from "./src/trpc"; diff --git a/packages/api/package.json b/packages/api/package.json index d897f3e..6e77f9c 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -1,24 +1,38 @@ { "name": "@acme/api", "version": "0.1.0", - "main": "./index.ts", - "types": "./index.ts", + "private": true, + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./src/index.ts" + } + }, "license": "MIT", "scripts": { + "build": "tsc", + "dev": "tsc --watch", "clean": "rm -rf .turbo node_modules", - "lint": "eslint .", - "type-check": "tsc --noEmit" + "format": "prettier --check . --ignore-path ../../.gitignore", + "lint": "eslint", + "typecheck": "tsc --noEmit --emitDeclarationOnly false" }, "dependencies": { - "@acme/auth": "*", - "@acme/db": "*", - "@trpc/client": "^10.9.0", - "@trpc/server": "^10.9.0", - "superjson": "1.12.2", - "zod": "^3.20.2" + "@acme/auth": "workspace:*", + "@acme/db": "workspace:*", + "@acme/validators": "workspace:*", + "@trpc/server": "11.0.0-rc.334", + "superjson": "2.2.1", + "zod": "^3.23.4" }, "devDependencies": { - "eslint": "^8.33.0", - "typescript": "^4.9.5" - } + "@acme/eslint-config": "workspace:*", + "@acme/prettier-config": "workspace:*", + "@acme/tsconfig": "workspace:*", + "eslint": "^9.1.1", + "prettier": "^3.2.5", + "typescript": "^5.4.5" + }, + "prettier": "@acme/prettier-config" } diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts new file mode 100644 index 0000000..1cbe6fd --- /dev/null +++ b/packages/api/src/index.ts @@ -0,0 +1,33 @@ +import type { inferRouterInputs, inferRouterOutputs } from "@trpc/server"; + +import type { AppRouter } from "./root"; +import { appRouter } from "./root"; +import { createCallerFactory, createTRPCContext } from "./trpc"; + +/** + * Create a server-side caller for the tRPC API + * @example + * const trpc = createCaller(createContext); + * const res = await trpc.post.all(); + * ^? Post[] + */ +const createCaller = createCallerFactory(appRouter); + +/** + * Inference helpers for input types + * @example + * type PostByIdInput = RouterInputs['post']['byId'] + * ^? { id: number } + **/ +type RouterInputs = inferRouterInputs; + +/** + * Inference helpers for output types + * @example + * type AllPostsOutput = RouterOutputs['post']['all'] + * ^? Post[] + **/ +type RouterOutputs = inferRouterOutputs; + +export { createTRPCContext, appRouter, createCaller }; +export type { AppRouter, RouterInputs, RouterOutputs }; diff --git a/packages/api/src/router/auth.ts b/packages/api/src/router/auth.ts index 6575fc2..a293d41 100644 --- a/packages/api/src/router/auth.ts +++ b/packages/api/src/router/auth.ts @@ -1,7 +1,7 @@ -import { createTRPCRouter, publicProcedure } from "../trpc"; +import { publicProcedure } from "../trpc"; -export const authRouter = createTRPCRouter({ +export const authRouter = { getSession: publicProcedure.query(({ ctx }) => { - return ctx.token; + return ctx.session; }), -}); +}; diff --git a/packages/api/src/router/team-member.ts b/packages/api/src/router/team-member.ts index 148a1c2..4898b5e 100644 --- a/packages/api/src/router/team-member.ts +++ b/packages/api/src/router/team-member.ts @@ -1,14 +1,15 @@ import { TRPCError } from "@trpc/server"; import { z } from "zod"; -import { createTRPCRouter, protectedProcedure } from "../trpc"; +import { and, eq, schema } from "@acme/db"; +import { CreateTeamMemberSchema } from "@acme/validators"; -export const teamMemberRouter = createTRPCRouter({ +import { protectedProcedure } from "../trpc"; + +export const teamMemberRouter = { all: protectedProcedure.input(z.string()).query(async ({ ctx, input }) => { - const team = await ctx.prisma.team.findUnique({ - where: { - id: input, - }, + const team = await ctx.db.query.team.findFirst({ + where: eq(schema.team.id, input), }); if (!team) { @@ -18,13 +19,11 @@ export const teamMemberRouter = createTRPCRouter({ }); } - const teamMember = await ctx.prisma.teamMember.findUnique({ - where: { - teamId_userId: { - teamId: team.id, - userId: ctx.token.user.id, - }, - }, + const teamMember = await ctx.db.query.teamMember.findFirst({ + where: and( + eq(schema.teamMember.teamId, team.id), + eq(schema.teamMember.userId, ctx.session.user.id), + ), }); if (teamMember?.role !== "ADMIN") { @@ -34,30 +33,35 @@ export const teamMemberRouter = createTRPCRouter({ }); } - const teamMembers = await ctx.prisma.teamMember.findMany({ - include: { - user: true, - }, - where: { - teamId: input, - }, - }); + const teamMembers = await ctx.db + .select({ + id: schema.users.id, + name: schema.users.name, + role: schema.teamMember.role, + }) + .from(schema.users) + .innerJoin( + schema.teamMember, + eq(schema.teamMember.userId, schema.users.id), + ) + .where(eq(schema.teamMember.teamId, team.id)); return teamMembers; }), get: protectedProcedure .input( z.object({ - userId: z.string().cuid().optional(), - teamId: z.string().cuid(), + userId: z.string().uuid().optional(), + teamId: z.string().uuid(), }), ) .query(async ({ ctx, input }) => { if (input.userId) { - const teamMember = await ctx.prisma.teamMember.findUnique({ - where: { - teamId_userId: { userId: ctx.token.user.id, teamId: input.teamId }, - }, + const teamMember = await ctx.db.query.teamMember.findFirst({ + where: and( + eq(schema.teamMember.teamId, input.teamId), + eq(schema.teamMember.userId, ctx.session.user.id), + ), }); if (!teamMember) { @@ -75,31 +79,35 @@ export const teamMemberRouter = createTRPCRouter({ } } - return ctx.prisma.teamMember.findUnique({ - include: { - user: true, - }, - where: { - teamId_userId: { - userId: input.userId ?? ctx.token.user.id, - teamId: input.teamId, - }, - }, - }); + const user = await ctx.db + .select({ + id: schema.users.id, + name: schema.users.name, + email: schema.users.email, + role: schema.teamMember.role, + dailyWorkload: schema.teamMember.dailyWorkload, + createdAt: schema.teamMember.createdAt, + }) + .from(schema.users) + .innerJoin( + schema.teamMember, + eq(schema.teamMember.userId, schema.users.id), + ) + .where( + and( + eq(schema.teamMember.teamId, input.teamId), + eq(schema.teamMember.userId, input.userId ?? ctx.session.user.id), + ), + ) + .limit(1); + + return user[0]; }), create: protectedProcedure - .input( - z.object({ - teamId: z.string().cuid(), - dailyWorkload: z.number().min(1).max(24).default(8), - initialBalanceInMinutes: z.number().default(0), - }), - ) + .input(CreateTeamMemberSchema) .mutation(async ({ ctx, input }) => { - const team = await ctx.prisma.team.findUnique({ - where: { - id: input.teamId, - }, + const team = await ctx.db.query.team.findFirst({ + where: eq(schema.team.id, input.teamId), }); if (!team) { @@ -109,13 +117,11 @@ export const teamMemberRouter = createTRPCRouter({ }); } - const teamMember = await ctx.prisma.teamMember.findUnique({ - where: { - teamId_userId: { - teamId: team.id, - userId: ctx.token.user.id, - }, - }, + const teamMember = await ctx.db.query.teamMember.findFirst({ + where: and( + eq(schema.teamMember.teamId, team.id), + eq(schema.teamMember.userId, ctx.session.user.id), + ), }); if (teamMember) { @@ -125,14 +131,12 @@ export const teamMemberRouter = createTRPCRouter({ }); } - await ctx.prisma.teamMember.create({ - data: { - teamId: team.id, - userId: ctx.token.user.id, - role: "MEMBER", - dailyWorkload: input.dailyWorkload, - initialBalanceInMinutes: input.initialBalanceInMinutes, - }, + await ctx.db.insert(schema.teamMember).values({ + teamId: team.id, + userId: ctx.session.user.id, + role: "MEMBER", + dailyWorkload: input.dailyWorkload, + initialBalanceInMinutes: input.initialBalanceInMinutes, }); return team; @@ -140,19 +144,17 @@ export const teamMemberRouter = createTRPCRouter({ update: protectedProcedure .input( z.object({ - teamId: z.string().cuid(), + teamId: z.string().uuid(), dailyWorkload: z.number().min(1).max(24).optional(), initialBalanceInMinutes: z.number().optional(), }), ) .mutation(async ({ ctx, input }) => { - const teamMember = await ctx.prisma.teamMember.findUnique({ - where: { - teamId_userId: { - teamId: input.teamId, - userId: ctx.token.user.id, - }, - }, + const teamMember = await ctx.db.query.teamMember.findFirst({ + where: and( + eq(schema.teamMember.teamId, input.teamId), + eq(schema.teamMember.userId, ctx.session.user.id), + ), }); if (!teamMember) { @@ -162,19 +164,19 @@ export const teamMemberRouter = createTRPCRouter({ }); } - await ctx.prisma.teamMember.update({ - where: { - teamId_userId: { - teamId: input.teamId, - userId: ctx.token.user.id, - }, - }, - data: { + await ctx.db + .update(schema.teamMember) + .set({ dailyWorkload: input.dailyWorkload, initialBalanceInMinutes: input.initialBalanceInMinutes, - }, - }); + }) + .where( + and( + eq(schema.teamMember.teamId, input.teamId), + eq(schema.teamMember.userId, ctx.session.user.id), + ), + ); return teamMember; }), -}); +}; diff --git a/packages/api/src/router/team.ts b/packages/api/src/router/team.ts index dfd30a1..0d66bb8 100644 --- a/packages/api/src/router/team.ts +++ b/packages/api/src/router/team.ts @@ -1,58 +1,76 @@ +import { TRPCError } from "@trpc/server"; import { z } from "zod"; -import { createTRPCRouter, protectedProcedure } from "../trpc"; +import { and, eq, schema } from "@acme/db"; +import { CreateTeamSchema } from "@acme/validators"; -export const teamRouter = createTRPCRouter({ +import { protectedProcedure } from "../trpc"; + +export const teamRouter = { all: protectedProcedure.query(({ ctx }) => { - return ctx.prisma.teamMember.findMany({ - include: { - team: true, - }, - where: { - userId: ctx.token.user.id, - }, - }); + return ctx.db + .select({ + id: schema.team.id, + name: schema.team.name, + role: schema.teamMember.role, + }) + .from(schema.team) + .innerJoin( + schema.teamMember, + eq(schema.team.id, schema.teamMember.teamId), + ) + .where(eq(schema.teamMember.userId, ctx.session.user.id)) + .orderBy(schema.teamMember.role); }), get: protectedProcedure - .input(z.string().cuid()) + .input(z.string().uuid()) .query(async ({ ctx, input }) => { - const team = await ctx.prisma.team.findFirst({ - include: { - TeamMember: true, - }, - where: { - id: input, - TeamMember: { - some: { - userId: ctx.token.user.id, - }, - }, - }, - }); + const team = await ctx.db + .select({ + id: schema.team.id, + name: schema.team.name, + role: schema.teamMember.role, + }) + .from(schema.team) + .innerJoin( + schema.teamMember, + eq(schema.team.id, schema.teamMember.teamId), + ) + .where( + and( + eq(schema.team.id, input), + eq(schema.teamMember.userId, ctx.session.user.id), + ), + ) + .limit(1); - return team; + return team[0]; }), create: protectedProcedure - .input( - z.object({ - name: z.string(), - }), - ) + .input(CreateTeamSchema) .mutation(async ({ ctx, input }) => { - const team = await ctx.prisma.team.create({ - data: { + const res = await ctx.db + .insert(schema.team) + .values({ name: input.name, - }, - }); + }) + .returning(); + + const team = res[0]; + + if (!team) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Time não encontrado", + }); + } - await ctx.prisma.teamMember.create({ - data: { - teamId: team.id, - userId: ctx.token.user.id, - role: "ADMIN", - }, + await ctx.db.insert(schema.teamMember).values({ + teamId: team.id, + userId: ctx.session.user.id, + role: "ADMIN", }); return team; }), -}); +}; diff --git a/packages/api/src/router/time-record.ts b/packages/api/src/router/time-record.ts index 5590356..b499bf7 100644 --- a/packages/api/src/router/time-record.ts +++ b/packages/api/src/router/time-record.ts @@ -1,25 +1,27 @@ import { TRPCError } from "@trpc/server"; import { z } from "zod"; -import { createTRPCRouter, protectedProcedure } from "../trpc"; +import { and, eq, gte, lte, schema } from "@acme/db"; -export const timeRecordRouter = createTRPCRouter({ +import { protectedProcedure } from "../trpc"; + +export const timeRecordRouter = { all: protectedProcedure .input( z.object({ start: z.date().optional(), end: z.date().optional(), - teamId: z.string().cuid(), - userId: z.string().cuid().optional(), + teamId: z.string().uuid(), + userId: z.string().optional(), }), ) .query(async ({ ctx, input }) => { - if (!!input.userId && ctx.token.user.id !== input.userId) { - const teamMember = await ctx.prisma.teamMember.findFirst({ - where: { - teamId: input.teamId, - userId: ctx.token.user.id, - }, + if (!!input.userId && ctx.session.user.id !== input.userId) { + const teamMember = await ctx.db.query.teamMember.findFirst({ + where: and( + eq(schema.teamMember.teamId, input.teamId), + eq(schema.teamMember.userId, ctx.session.user.id), + ), }); if (teamMember?.role !== "ADMIN") { @@ -30,21 +32,20 @@ export const timeRecordRouter = createTRPCRouter({ } } - return ctx.prisma.timeRecord.findMany({ - select: { + return ctx.db.query.timeRecord.findMany({ + columns: { id: true, teamId: true, userId: true, time: true, }, - where: { - userId: input.userId ?? ctx.token.user.id, - time: { gte: input?.start, lte: input?.end }, - teamId: input.teamId, - }, - orderBy: { - time: "asc", - }, + where: and( + eq(schema.timeRecord.teamId, input.teamId), + eq(schema.timeRecord.userId, input.userId ?? ctx.session.user.id), + input.start && gte(schema.timeRecord.time, input.start), + input.end && lte(schema.timeRecord.time, input.end), + ), + orderBy: schema.timeRecord.time, }); }), create: protectedProcedure @@ -55,12 +56,10 @@ export const timeRecordRouter = createTRPCRouter({ }), ) .mutation(({ ctx, input }) => { - return ctx.prisma.timeRecord.create({ - data: { - userId: ctx.token.user.id, - teamId: input.teamId, - time: input.time, - }, + return ctx.db.insert(schema.timeRecord).values({ + userId: ctx.session.user.id, + teamId: input.teamId, + time: input.time, }); }), batch: protectedProcedure @@ -71,41 +70,37 @@ export const timeRecordRouter = createTRPCRouter({ }), ) .mutation(async ({ ctx, input }) => { - return ctx.prisma.timeRecord.createMany({ - data: input.timeRecords.map((time) => ({ - userId: ctx.token.user.id, + return ctx.db.insert(schema.timeRecord).values( + input.timeRecords.map((time) => ({ + userId: ctx.session.user.id, teamId: input.teamId, time, })), - }); - }), - delete: protectedProcedure - .input(z.string().cuid()) - .mutation(({ ctx, input }) => { - return ctx.prisma.timeRecord.delete({ - where: { - id: input, - }, - }); + ); }), + delete: protectedProcedure.input(z.number()).mutation(({ ctx, input }) => { + return ctx.db + .delete(schema.timeRecord) + .where(eq(schema.timeRecord.id, input)); + }), history: protectedProcedure .input( z.object({ - teamId: z.string().cuid(), - userId: z.string().cuid().optional(), + teamId: z.string().uuid(), + userId: z.string().uuid().optional(), }), ) .query(async ({ ctx, input }) => { - const teamMember = await ctx.prisma.teamMember.findFirst({ - where: { - teamId: input.teamId, - userId: ctx.token.user.id, - }, + const teamMember = await ctx.db.query.teamMember.findFirst({ + where: and( + eq(schema.teamMember.teamId, input.teamId), + eq(schema.teamMember.userId, ctx.session.user.id), + ), }); if ( !!input.userId && - ctx.token.user.id !== input.userId && + ctx.session.user.id !== input.userId && teamMember?.role !== "ADMIN" ) { throw new TRPCError({ @@ -114,32 +109,35 @@ export const timeRecordRouter = createTRPCRouter({ }); } - const timeRecords = await ctx.prisma.timeRecord.findMany({ - select: { + const timeRecords = await ctx.db.query.timeRecord.findMany({ + columns: { time: true, }, - where: { - userId: input.userId ?? ctx.token.user.id, - teamId: input.teamId, - }, - orderBy: { - time: "asc", - }, + where: and( + eq(schema.timeRecord.teamId, input.teamId), + eq(schema.timeRecord.userId, input.userId ?? ctx.session.user.id), + ), + orderBy: schema.timeRecord.time, }); - const groupByYearMonthDay = - timeRecords?.reduce((acc, timeRecord) => { + const groupByYearMonthDay = timeRecords.reduce( + (acc, timeRecord) => { const day = timeRecord.time.getDate(); const month = timeRecord.time.getMonth(); const year = timeRecord.time.getFullYear(); if (!acc[year]) acc[year] = {}; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion if (!acc[year]?.[month]) acc[year]![month] = {}; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion if (!acc[year]?.[month]?.[day]) acc[year]![month]![day] = []; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion acc[year]![month]![day]!.push(timeRecord.time); return acc; - }, {} as Record>>) ?? {}; + }, + {} as Record>>, + ); const historyResult: { label: string; @@ -183,4 +181,4 @@ export const timeRecordRouter = createTRPCRouter({ return historyResult; }), -}); +}; diff --git a/packages/api/src/trpc.ts b/packages/api/src/trpc.ts index 11cbfd6..bad97b6 100644 --- a/packages/api/src/trpc.ts +++ b/packages/api/src/trpc.ts @@ -6,82 +6,63 @@ * tl;dr - this is where all the tRPC server stuff is created and plugged in. * The pieces you will need to use are documented accordingly near the end */ +import { initTRPC, TRPCError } from "@trpc/server"; +import superjson from "superjson"; +import { ZodError } from "zod"; + +import type { Session } from "@acme/auth"; +import { db } from "@acme/db"; /** * 1. CONTEXT * - * This section defines the "contexts" that are available in the backend API + * This section defines the "contexts" that are available in the backend API. * - * These allow you to access things like the database, the session, etc, when - * processing a request + * These allow you to access things when processing a request, like the database, the session, etc. * - */ - -/** - * 2. INITIALIZATION + * This helper generates the "internals" for a tRPC context. The API handler and RSC clients each + * wrap this and provides the required context. * - * This is where the trpc api is initialized, connecting the context and - * transformer + * @see https://trpc.io/docs/server/context */ -import { TRPCError, initTRPC } from "@trpc/server"; -import { type CreateNextContextOptions } from "@trpc/server/adapters/next"; -import superjson from "superjson"; -import { - // getServerSession, - // type Session, - getServerToken, - type JWT, -} from "@acme/auth"; -import { prisma } from "@acme/db"; +export const createTRPCContext = (opts: { + headers: Headers; + session: Session | null; +}) => { + const session = opts.session; + const source = opts.headers.get("x-trpc-source") ?? "unknown"; -type CreateContextOptions = { - // session: Session | null; - token: JWT | null; -}; + console.log(">>> tRPC Request from", source, "by", session?.user); -/** - * This helper generates the "internals" for a tRPC context. If you need to use - * it, you can export it from here - * - * Examples of things you may need it for: - * - testing, so we dont have to mock Next.js' req/res - * - trpc's `createSSGHelpers` where we don't have req/res - * @see https://create.t3.gg/en/usage/trpc#-servertrpccontextts - */ -export const createInnerTRPCContext = (opts: CreateContextOptions) => { return { - // session: opts.session, - token: opts.token, - prisma, + session, + db, }; }; /** - * This is the actual context you'll use in your router. It will be used to - * process every request that goes through your tRPC endpoint - * @link https://trpc.io/docs/context + * 2. INITIALIZATION + * + * This is where the trpc api is initialized, connecting the context and + * transformer */ -export const createTRPCContext = async (opts: CreateNextContextOptions) => { - const { req } = opts; - - // Get the session from the server using the unstable_getServerSession wrapper function - // const session = await getServerSession({ req, res }); - - const token = await getServerToken({ req }); - - return createInnerTRPCContext({ - // session, - token, - }); -}; - const t = initTRPC.context().create({ transformer: superjson, - errorFormatter({ shape }) { - return shape; - }, + errorFormatter: ({ shape, error }) => ({ + ...shape, + data: { + ...shape.data, + zodError: error.cause instanceof ZodError ? error.cause.flatten() : null, + }, + }), }); +/** + * Create a server-side caller + * @see https://trpc.io/docs/server/server-side-calls + */ +export const createCallerFactory = t.createCallerFactory; + /** * 3. ROUTER & PROCEDURE (THE IMPORTANT BIT) * @@ -105,38 +86,24 @@ export const createTRPCRouter = t.router; export const publicProcedure = t.procedure; /** - * Reusable middleware that enforces users are logged in before running the - * procedure + * Protected (authenticated) procedure + * + * If you want a query or mutation to ONLY be accessible to logged in users, use this. It verifies + * the session is valid and guarantees `ctx.session.user` is not null. + * + * @see https://trpc.io/docs/procedures */ -const enforceUserIsAuthed = t.middleware(({ ctx, next }) => { - // if (!ctx.session || !ctx.session.user) { - // throw new TRPCError({ code: "UNAUTHORIZED" }); - // } - // return next({ - // ctx: { - // // infers the `session` as non-nullable - // session: { ...ctx.session, user: ctx.session.user }, - // }, - // }); - - if (!ctx.token?.user) { - throw new TRPCError({ code: "UNAUTHORIZED" }); +export const protectedProcedure = t.procedure.use(({ ctx, next }) => { + if (!ctx.session?.user) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "É necessário estar logado", + }); } return next({ ctx: { - // infers the `token` as non-nullable - token: { ...ctx.token, user: ctx.token.user }, + // infers the `session` as non-nullable + session: { ...ctx.session, user: ctx.session.user }, }, }); }); - -/** - * Protected (authed) procedure - * - * If you want a query or mutation to ONLY be accessible to logged in users, use - * this. It verifies the session is valid and guarantees ctx.session.user is not - * null - * - * @see https://trpc.io/docs/procedures - */ -export const protectedProcedure = t.procedure.use(enforceUserIsAuthed); diff --git a/packages/api/transformer.ts b/packages/api/transformer.ts deleted file mode 100644 index 1e84e8f..0000000 --- a/packages/api/transformer.ts +++ /dev/null @@ -1,3 +0,0 @@ -import superjson from "superjson"; - -export const transformer = superjson; diff --git a/packages/api/tsconfig.json b/packages/api/tsconfig.json index 29e64f0..ed40c5d 100644 --- a/packages/api/tsconfig.json +++ b/packages/api/tsconfig.json @@ -1,4 +1,9 @@ { - "extends": "../../tsconfig.json", - "include": ["src", "index.ts", "transformer.ts"] + "extends": "@acme/tsconfig/internal-package.json", + "compilerOptions": { + "outDir": "dist", + "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" + }, + "include": ["src"], + "exclude": ["node_modules"] } diff --git a/packages/auth/env.ts b/packages/auth/env.ts new file mode 100644 index 0000000..770c20d --- /dev/null +++ b/packages/auth/env.ts @@ -0,0 +1,17 @@ +/* eslint-disable no-restricted-properties */ +import { createEnv } from "@t3-oss/env-nextjs"; +import { z } from "zod"; + +export const env = createEnv({ + server: { + AUTH_DISCORD_ID: z.string().min(1), + AUTH_DISCORD_SECRET: z.string().min(1), + AUTH_SECRET: + process.env.NODE_ENV === "production" + ? z.string().min(1) + : z.string().min(1).optional(), + }, + client: {}, + experimental__runtimeEnv: {}, + skipValidation: !!process.env.CI || !!process.env.SKIP_ENV_VALIDATION, +}); diff --git a/packages/auth/eslint.config.js b/packages/auth/eslint.config.js new file mode 100644 index 0000000..61cafcb --- /dev/null +++ b/packages/auth/eslint.config.js @@ -0,0 +1,10 @@ +import baseConfig, { restrictEnvAccess } from "@acme/eslint-config/base"; + +/** @type {import('typescript-eslint').Config} */ +export default [ + { + ignores: [], + }, + ...baseConfig, + ...restrictEnvAccess, +]; diff --git a/packages/auth/index.ts b/packages/auth/index.ts deleted file mode 100644 index eb9b6ca..0000000 --- a/packages/auth/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { authOptions } from "./src/auth-options"; -// export { getServerSession } from "./src/get-session"; -export { getServerToken } from "./src/get-token"; -export type { Session } from "next-auth"; -export type { JWT } from "next-auth/jwt"; diff --git a/packages/auth/package.json b/packages/auth/package.json index da0588c..3f65722 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -1,24 +1,39 @@ { "name": "@acme/auth", "version": "0.1.0", - "main": "./index.ts", - "types": "./index.ts", + "private": true, + "type": "module", + "exports": { + ".": { + "react-server": "./src/index.rsc.ts", + "default": "./src/index.ts" + }, + "./env": "./env.ts" + }, "license": "MIT", "scripts": { "clean": "rm -rf .turbo node_modules", - "lint": "eslint .", - "type-check": "tsc --noEmit" + "format": "prettier --check . --ignore-path ../../.gitignore", + "lint": "eslint", + "typecheck": "tsc --noEmit" }, "dependencies": { - "@acme/db": "*", - "@next-auth/prisma-adapter": "^1.0.5", - "next": "^13.1.6", - "next-auth": "^4.19.0", - "react": "18.2.0", - "react-dom": "18.2.0" + "@acme/db": "workspace:*", + "@auth/drizzle-adapter": "^1.0.1", + "@t3-oss/env-nextjs": "^0.10.1", + "next": "^14.2.3", + "next-auth": "5.0.0-beta.17", + "react": "18.3.1", + "react-dom": "18.3.1", + "zod": "^3.23.4" }, "devDependencies": { - "eslint": "^8.33.0", - "typescript": "^4.9.5" - } + "@acme/eslint-config": "workspace:*", + "@acme/prettier-config": "workspace:*", + "@acme/tsconfig": "workspace:*", + "eslint": "^9.1.1", + "prettier": "^3.2.5", + "typescript": "^5.4.5" + }, + "prettier": "@acme/prettier-config" } diff --git a/packages/auth/src/auth-options.ts b/packages/auth/src/auth-options.ts deleted file mode 100644 index 1dc7969..0000000 --- a/packages/auth/src/auth-options.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { PrismaAdapter } from "@next-auth/prisma-adapter"; -import { type NextAuthOptions } from "next-auth"; -// import { type DefaultSession } from "next-auth"; -import { type DefaultJWT } from "next-auth/jwt"; -import DiscordProvider from "next-auth/providers/discord"; -import { prisma } from "@acme/db"; - -/** - * Module augmentation for `next-auth` types - * Allows us to add custom properties to the `session` object - * and keep type safety - * @see https://next-auth.js.org/getting-started/typescript#module-augmentation - */ - -// declare module "next-auth" { -// interface Session extends DefaultSession { -// user: { -// id: string; -// } & DefaultSession["user"]; -// } -// } - -declare module "next-auth/jwt" { - /** Returned by the `jwt` callback and `getToken`, when using JWT sessions */ - interface JWT extends DefaultJWT { - user: { - id: string; - } & DefaultJWT; - } -} - -/** - * Options for NextAuth.js used to configure - * adapters, providers, callbacks, etc. - * @see https://next-auth.js.org/configuration/options - **/ -export const authOptions: NextAuthOptions = { - pages: { - signIn: "/sign-in", - }, - callbacks: { - // session({ session, user }) { - // if (session.user) { - // session.user.id = user.id; - // } - // return session; - // }, - jwt({ token, user }) { - if (user) { - token.user = { - ...token, - id: user.id, - }; - } - return token; - }, - async redirect(_) { - return "/team"; - }, - }, - adapter: PrismaAdapter(prisma), - session: { - strategy: "jwt", - }, - jwt: { - maxAge: 60, - }, - providers: [ - DiscordProvider({ - clientId: process.env.DISCORD_CLIENT_ID as string, - clientSecret: process.env.DISCORD_CLIENT_SECRET as string, - }), - // ...add more providers here - ], -}; diff --git a/packages/auth/src/config.ts b/packages/auth/src/config.ts new file mode 100644 index 0000000..6a7e3da --- /dev/null +++ b/packages/auth/src/config.ts @@ -0,0 +1,36 @@ +import type { DefaultSession, NextAuthConfig } from "next-auth"; +import { DrizzleAdapter } from "@auth/drizzle-adapter"; +import Discord from "next-auth/providers/discord"; + +import { db, schema } from "@acme/db"; + +declare module "next-auth" { + interface Session { + user: { + id: string; + } & DefaultSession["user"]; + } +} + +export const authConfig = { + adapter: DrizzleAdapter(db, { + usersTable: schema.users, + accountsTable: schema.accounts, + sessionsTable: schema.sessions, + verificationTokensTable: schema.verificationTokens, + }), + providers: [Discord], + callbacks: { + session: (opts) => { + if (!("user" in opts)) throw "unreachable with session strategy"; + + return { + ...opts.session, + user: { + ...opts.session.user, + id: opts.user.id, + }, + }; + }, + }, +} satisfies NextAuthConfig; diff --git a/packages/auth/src/get-session.ts b/packages/auth/src/get-session.ts deleted file mode 100644 index 482a0e7..0000000 --- a/packages/auth/src/get-session.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { - GetServerSidePropsContext, - NextApiRequest, - NextApiResponse, -} from "next"; -import { getServerSession as $getServerSession } from "next-auth"; - -import { authOptions } from "./auth-options"; - -type GetServerSessionContext = - | { - req: GetServerSidePropsContext["req"]; - res: GetServerSidePropsContext["res"]; - } - | { req: NextApiRequest; res: NextApiResponse }; -export const getServerSession = (ctx: GetServerSessionContext) => { - return $getServerSession(ctx.req, ctx.res, authOptions); -}; diff --git a/packages/auth/src/get-token.ts b/packages/auth/src/get-token.ts deleted file mode 100644 index 55e6b97..0000000 --- a/packages/auth/src/get-token.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { GetServerSidePropsContext, NextApiRequest } from "next"; -import { getToken } from "next-auth/jwt"; - -export const getServerToken = async ( - ctx: - | { - req: GetServerSidePropsContext["req"]; - } - | { req: NextApiRequest }, -) => { - const token = await getToken({ - req: ctx.req, - secret: process.env.NEXTAUTH_SECRET, - }); - return token; -}; diff --git a/packages/auth/src/index.rsc.ts b/packages/auth/src/index.rsc.ts new file mode 100644 index 0000000..4094ae6 --- /dev/null +++ b/packages/auth/src/index.rsc.ts @@ -0,0 +1,21 @@ +import { cache } from "react"; +import NextAuth from "next-auth"; + +import { authConfig } from "./config"; + +export type { Session } from "next-auth"; + +const { + handlers: { GET, POST }, + auth: defaultAuth, + signIn, + signOut, +} = NextAuth(authConfig); + +/** + * This is the main way to get session data for your RSCs. + * This will de-duplicate all calls to next-auth's default `auth()` function and only call it once per request + */ +const auth = cache(defaultAuth); + +export { GET, POST, auth, signIn, signOut }; diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts new file mode 100644 index 0000000..47df456 --- /dev/null +++ b/packages/auth/src/index.ts @@ -0,0 +1,14 @@ +import NextAuth from "next-auth"; + +import { authConfig } from "./config"; + +export type { Session } from "next-auth"; + +const { + handlers: { GET, POST }, + auth, + signIn, + signOut, +} = NextAuth(authConfig); + +export { GET, POST, auth, signIn, signOut }; diff --git a/packages/auth/tsconfig.json b/packages/auth/tsconfig.json index 5a4e72b..8bbf4dc 100644 --- a/packages/auth/tsconfig.json +++ b/packages/auth/tsconfig.json @@ -1,4 +1,8 @@ { - "extends": "../../tsconfig.json", - "include": ["src", "index.ts"] + "extends": "@acme/tsconfig/base.json", + "compilerOptions": { + "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" + }, + "include": ["src", "*.ts"], + "exclude": ["node_modules"] } diff --git a/packages/config/tailwind/index.js b/packages/config/tailwind/index.js deleted file mode 100644 index de550dd..0000000 --- a/packages/config/tailwind/index.js +++ /dev/null @@ -1,8 +0,0 @@ -/** @type {import('tailwindcss').Config} */ -module.exports = { - content: ["./src/**/*.{ts,tsx}"], - theme: { - extend: {}, - }, - plugins: [], -}; diff --git a/packages/config/tailwind/package.json b/packages/config/tailwind/package.json deleted file mode 100644 index 4cddf02..0000000 --- a/packages/config/tailwind/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "@acme/tailwind-config", - "version": "0.1.0", - "main": "index.js", - "license": "MIT", - "files": [ - "index.js", - "postcss.js" - ], - "devDependencies": { - "autoprefixer": "^10.4.13", - "postcss": "^8.4.21", - "tailwindcss": "^3.2.4" - } -} diff --git a/packages/config/tailwind/postcss.js b/packages/config/tailwind/postcss.js deleted file mode 100644 index 12a703d..0000000 --- a/packages/config/tailwind/postcss.js +++ /dev/null @@ -1,6 +0,0 @@ -module.exports = { - plugins: { - tailwindcss: {}, - autoprefixer: {}, - }, -}; diff --git a/packages/db/eslint.config.js b/packages/db/eslint.config.js new file mode 100644 index 0000000..b87792c --- /dev/null +++ b/packages/db/eslint.config.js @@ -0,0 +1,9 @@ +import baseConfig from "@acme/eslint-config/base"; + +/** @type {import('typescript-eslint').Config} */ +export default [ + { + ignores: ["dist/**"], + }, + ...baseConfig, +]; diff --git a/packages/db/index.ts b/packages/db/index.ts deleted file mode 100644 index a461c6d..0000000 --- a/packages/db/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { PrismaClient } from "@prisma/client"; - -export * from "@prisma/client"; - -const globalForPrisma = globalThis as unknown as { prisma: PrismaClient }; - -export const prisma = - globalForPrisma.prisma || - new PrismaClient({ - log: process.env.NODE_ENV === "development" ? ["error", "warn"] : ["error"], - }); - -if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma; diff --git a/packages/db/package.json b/packages/db/package.json index bdc5445..957bd4f 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -1,25 +1,42 @@ { "name": "@acme/db", "version": "0.1.0", - "main": "./index.ts", - "types": "./index.ts", + "private": true, + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./src/index.ts" + } + }, "license": "MIT", "scripts": { + "build": "tsc", + "dev": "tsc --watch", "clean": "rm -rf .turbo node_modules", - "db:generate": "pnpm with-env prisma generate", - "db:push": "pnpm with-env prisma db push --skip-generate --accept-data-loss", - "dev": "pnpm with-env prisma studio --port 5556", + "format": "prettier --check . --ignore-path ../../.gitignore", + "lint": "eslint", + "push": "pnpm with-env drizzle-kit push:pg --config src/config.ts", + "studio": "pnpm with-env drizzle-kit studio --config src/config.ts", + "typecheck": "tsc --noEmit --emitDeclarationOnly false", "with-env": "dotenv -e ../../.env --" }, "dependencies": { - "@planetscale/database": "^1.5.0", - "@prisma/client": "^4.10.0", - "kysely": "^0.23.4", - "kysely-planetscale": "^1.2.1" + "@neondatabase/serverless": "^0.9.1", + "@t3-oss/env-core": "^0.10.1", + "drizzle-orm": "^0.30.9", + "zod": "^3.23.4" }, "devDependencies": { - "dotenv-cli": "^7.0.0", - "prisma": "^4.10.0", - "typescript": "^4.9.5" - } + "@acme/eslint-config": "workspace:*", + "@acme/prettier-config": "workspace:*", + "@acme/tsconfig": "workspace:*", + "dotenv-cli": "^7.4.1", + "drizzle-kit": "^0.20.14", + "eslint": "^9.1.1", + "pg": "^8.11.5", + "prettier": "^3.2.5", + "typescript": "^5.4.5" + }, + "prettier": "@acme/prettier-config" } diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma deleted file mode 100644 index 758723d..0000000 --- a/packages/db/prisma/schema.prisma +++ /dev/null @@ -1,111 +0,0 @@ -// This is your Prisma schema file, -// learn more about it in the docs: https://pris.ly/d/prisma-schema - -generator client { - provider = "prisma-client-js" -} - -datasource db { - provider = "mysql" - url = env("DATABASE_URL") - relationMode = "prisma" -} - -model TimeRecord { - id String @id @default(cuid()) - userId String - teamId String - time DateTime @default(now()) - createdAt DateTime @default(now()) - - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) - - @@index([userId]) - @@index([teamId]) -} - -model Team { - id String @id @default(cuid()) - name String - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - TeamMember TeamMember[] - TimeRecord TimeRecord[] -} - -model TeamMember { - id String @id @default(cuid()) - teamId String - userId String - role TeamRole @default(MEMBER) - dailyWorkload Int @default(8) - initialBalanceInMinutes Int @default(0) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - - @@unique([teamId, userId]) - @@index([teamId]) - @@index([userId]) -} - -enum TeamRole { - ADMIN - MEMBER -} - -// NextAuth.js Models -// NOTE: When using postgresql, mysql or sqlserver, -// uncomment the @db.Text annotations below -// @see https://next-auth.js.org/schemas/models -model Account { - id String @id @default(cuid()) - userId String - type String - provider String - providerAccountId String - refresh_token String? @db.Text - access_token String? @db.Text - expires_at Int? - token_type String? - scope String? - id_token String? @db.Text - session_state String? - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - - @@unique([provider, providerAccountId]) - @@index([userId]) -} - -model Session { - id String @id @default(cuid()) - sessionToken String @unique - userId String - expires DateTime - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - - @@index([userId]) -} - -model User { - id String @id @default(cuid()) - name String? - email String? @unique - emailVerified DateTime? - image String? - accounts Account[] - sessions Session[] - TimeRecord TimeRecord[] - TeamMember TeamMember[] -} - -model VerificationToken { - identifier String - token String @unique - expires DateTime - - @@unique([identifier, token]) -} diff --git a/packages/db/src/config.ts b/packages/db/src/config.ts new file mode 100644 index 0000000..9de5cd5 --- /dev/null +++ b/packages/db/src/config.ts @@ -0,0 +1,29 @@ +import type { Config } from "drizzle-kit"; +import { createEnv } from "@t3-oss/env-core"; +import * as z from "zod"; + +const env = createEnv({ + server: { + DB_HOST: z.string(), + DB_NAME: z.string(), + DB_USERNAME: z.string(), + DB_PASSWORD: z.string(), + }, + runtimeEnv: process.env, + emptyStringAsUndefined: true, +}); + +// Push requires SSL so use URL instead of username/password +export const connectionStr = new URL( + `postgresql://${env.DB_HOST}/${env.DB_NAME}`, +); +connectionStr.username = env.DB_USERNAME; +connectionStr.password = env.DB_PASSWORD; +connectionStr.searchParams.set("sslmode", "require"); + +export default { + schema: "./src/schema", + driver: "pg", + dbCredentials: { connectionString: connectionStr.href }, + tablesFilter: ["t3turbo_*"], +} satisfies Config; diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts new file mode 100644 index 0000000..f6de742 --- /dev/null +++ b/packages/db/src/index.ts @@ -0,0 +1,14 @@ +import { neon } from "@neondatabase/serverless"; +import { drizzle } from "drizzle-orm/neon-http"; + +import { connectionStr } from "./config"; +import * as auth from "./schema/auth"; +import * as team from "./schema/team"; + +export { alias } from "drizzle-orm/pg-core"; +export * from "drizzle-orm/sql"; + +export const schema = { ...auth, ...team }; + +const neonClient = neon(connectionStr.href); +export const db = drizzle(neonClient, { schema }); diff --git a/packages/db/src/schema/_table.ts b/packages/db/src/schema/_table.ts new file mode 100644 index 0000000..d0748f6 --- /dev/null +++ b/packages/db/src/schema/_table.ts @@ -0,0 +1,9 @@ +import { pgTableCreator } from "drizzle-orm/pg-core"; + +/** + * This is an example of how to use the multi-project schema feature of Drizzle ORM. + * Use the same database instance for multiple projects. + * + * @see https://orm.drizzle.team/docs/goodies#multi-project-schema + */ +export const pgSqlTable = pgTableCreator((name) => `ponto_${name}`); diff --git a/packages/db/src/schema/auth.ts b/packages/db/src/schema/auth.ts new file mode 100644 index 0000000..40f7898 --- /dev/null +++ b/packages/db/src/schema/auth.ts @@ -0,0 +1,55 @@ +import { integer, primaryKey, text, timestamp } from "drizzle-orm/pg-core"; + +import { pgSqlTable } from "./_table"; + +export const users = pgSqlTable("user", { + id: text("id").notNull().primaryKey(), + name: text("name"), + email: text("email").notNull(), + emailVerified: timestamp("emailVerified", { mode: "date" }), + image: text("image"), +}); + +export const accounts = pgSqlTable( + "account", + { + userId: text("userId") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + type: text("type").notNull(), + provider: text("provider").notNull(), + providerAccountId: text("providerAccountId").notNull(), + refresh_token: text("refresh_token"), + access_token: text("access_token"), + expires_at: integer("expires_at"), + token_type: text("token_type"), + scope: text("scope"), + id_token: text("id_token"), + session_state: text("session_state"), + }, + (account) => ({ + compoundKey: primaryKey({ + columns: [account.provider, account.providerAccountId], + }), + }), +); + +export const sessions = pgSqlTable("session", { + sessionToken: text("sessionToken").notNull().primaryKey(), + userId: text("userId") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + expires: timestamp("expires", { mode: "date" }).notNull(), +}); + +export const verificationTokens = pgSqlTable( + "verificationToken", + { + identifier: text("identifier").notNull(), + token: text("token").notNull(), + expires: timestamp("expires", { mode: "date" }).notNull(), + }, + (vt) => ({ + compoundKey: primaryKey({ columns: [vt.identifier, vt.token] }), + }), +); diff --git a/packages/db/src/schema/team.ts b/packages/db/src/schema/team.ts new file mode 100644 index 0000000..097ca51 --- /dev/null +++ b/packages/db/src/schema/team.ts @@ -0,0 +1,78 @@ +import { relations, sql } from "drizzle-orm"; +import { + pgEnum, + serial, + text, + timestamp, + unique, + uuid, + varchar, +} from "drizzle-orm/pg-core"; + +import { pgSqlTable } from "./_table"; +import { users } from "./auth"; + +export const timeRecord = pgSqlTable( + "time_record", + { + id: serial("id").primaryKey(), + userId: text("user_id").notNull(), + teamId: uuid("team_id").notNull(), + time: timestamp("time") + .default(sql`CURRENT_TIMESTAMP`) + .notNull(), + createdAt: timestamp("created_at") + .default(sql`CURRENT_TIMESTAMP`) + .notNull(), + }, + // (timeRecord) => ({ + // userIdIdx: index("user_id_idx").on(timeRecord.userId), + // teamIdIdx: index("team_id_idx").on(timeRecord.teamId), + // }), +); + +export const timeRecordRelations = relations(timeRecord, ({ one }) => ({ + user: one(users, { fields: [timeRecord.userId], references: [users.id] }), + team: one(team, { fields: [timeRecord.teamId], references: [team.id] }), +})); + +export const team = pgSqlTable("team", { + id: uuid("id").primaryKey().defaultRandom(), + name: varchar("name", { length: 256 }).notNull(), + createdAt: timestamp("created_at") + .default(sql`CURRENT_TIMESTAMP`) + .notNull(), + updatedAt: timestamp("updated_at").$onUpdateFn(() => sql`NOW()`), +}); + +export const teamRelations = relations(team, ({ many }) => ({ + members: many(teamMember), +})); + +export const roleEnum = pgEnum("role", ["ADMIN", "MEMBER"]); + +export const teamMember = pgSqlTable( + "team_member", + { + id: serial("id").primaryKey(), + teamId: uuid("team_id").notNull(), + userId: text("user_id").notNull(), + role: roleEnum("role").notNull(), + dailyWorkload: serial("daily_workload").notNull(), + initialBalanceInMinutes: serial("initial_balance_in_minutes").notNull(), + createdAt: timestamp("created_at") + .default(sql`CURRENT_TIMESTAMP`) + .notNull(), + updatedAt: timestamp("updated_at").$onUpdateFn(() => sql`NOW()`), + }, + (teamMember) => ({ + unique: unique().on(teamMember.teamId, teamMember.userId), + // teamIdIdx: index("team_id_idx").on(teamMember.teamId), + // userIdIdx: index("user_id_idx").on(teamMember.userId), + }), +); + +export const teamMemberRelations = relations(teamMember, ({ one }) => ({ + user: one(users, { fields: [teamMember.userId], references: [users.id] }), + team: one(team, { fields: [teamMember.teamId], references: [team.id] }), +})); diff --git a/packages/db/tsconfig.json b/packages/db/tsconfig.json index 4fe4905..ed40c5d 100644 --- a/packages/db/tsconfig.json +++ b/packages/db/tsconfig.json @@ -1,4 +1,9 @@ { - "extends": "../../tsconfig.json", - "include": ["index.ts"] + "extends": "@acme/tsconfig/internal-package.json", + "compilerOptions": { + "outDir": "dist", + "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" + }, + "include": ["src"], + "exclude": ["node_modules"] } diff --git a/packages/db/utils/kysely.ts b/packages/db/utils/kysely.ts deleted file mode 100644 index 43b431d..0000000 --- a/packages/db/utils/kysely.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Team, TeamMember, User } from "@prisma/client"; -import { Kysely } from "kysely"; -import { PlanetScaleDialect } from "kysely-planetscale"; - -interface DB { - User: User; - Team: Team; - TeamMember: TeamMember; -} - -export const connectEdge = () => { - const username = process.env.PLANETSCALE_SERVELESS_USERNAME as string; - const password = process.env.PLANETSCALE_SERVELESS_PASSWORD as string; - - return new Kysely({ - dialect: new PlanetScaleDialect({ - host: "aws.connect.psdb.cloud", - username, - password, - }), - }); -}; diff --git a/packages/ui/components.json b/packages/ui/components.json new file mode 100644 index 0000000..c262488 --- /dev/null +++ b/packages/ui/components.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "./tailwind.config.ts", + "css": "unused.css", + "baseColor": "zinc", + "cssVariables": true + }, + "aliases": { + "utils": "@acme/ui", + "components": "src/", + "ui": "src/" + } +} diff --git a/packages/ui/eslint.config.js b/packages/ui/eslint.config.js new file mode 100644 index 0000000..9d74300 --- /dev/null +++ b/packages/ui/eslint.config.js @@ -0,0 +1,11 @@ +import baseConfig from "@acme/eslint-config/base"; +import reactConfig from "@acme/eslint-config/react"; + +/** @type {import('typescript-eslint').Config} */ +export default [ + { + ignores: [], + }, + ...baseConfig, + ...reactConfig, +]; diff --git a/packages/ui/package.json b/packages/ui/package.json new file mode 100644 index 0000000..458e66b --- /dev/null +++ b/packages/ui/package.json @@ -0,0 +1,56 @@ +{ + "name": "@acme/ui", + "private": true, + "version": "0.1.0", + "type": "module", + "exports": { + ".": "./src/index.ts", + "./*": "./src/*.tsx" + }, + "license": "MIT", + "scripts": { + "add": "pnpm dlx shadcn-ui add", + "clean": "rm -rf .turbo node_modules", + "format": "prettier --check . --ignore-path ../../.gitignore", + "lint": "eslint", + "typecheck": "tsc --noEmit --emitDeclarationOnly false", + "ui-add": "pnpm dlx shadcn-ui add && prettier src --write --list-different" + }, + "dependencies": { + "@hookform/resolvers": "^3.3.4", + "@radix-ui/react-dialog": "^1.0.5", + "@radix-ui/react-dropdown-menu": "^2.0.6", + "@radix-ui/react-icons": "^1.3.0", + "@radix-ui/react-label": "^2.0.2", + "@radix-ui/react-popover": "^1.0.7", + "@radix-ui/react-slot": "^1.0.2", + "@radix-ui/react-tabs": "^1.0.4", + "@radix-ui/react-tooltip": "^1.0.7", + "class-variance-authority": "^0.7.0", + "date-fns": "^3.6.0", + "next-themes": "^0.3.0", + "react-day-picker": "^8.10.1", + "react-hook-form": "^7.51.1", + "sonner": "^1.4.41", + "tailwind-merge": "^2.2.2", + "tailwindcss-animate": "^1.0.7" + }, + "devDependencies": { + "@acme/eslint-config": "workspace:*", + "@acme/prettier-config": "workspace:*", + "@acme/tailwind-config": "workspace:*", + "@acme/tsconfig": "workspace:*", + "@types/react": "^18.3.1", + "eslint": "^9.1.1", + "prettier": "^3.2.5", + "react": "18.3.1", + "tailwindcss": "^3.4.3", + "typescript": "^5.4.5", + "zod": "^3.23.4" + }, + "peerDependencies": { + "react": "18.2.0", + "zod": "^3.22.4" + }, + "prettier": "@acme/prettier-config" +} diff --git a/packages/ui/src/badge.tsx b/packages/ui/src/badge.tsx new file mode 100644 index 0000000..7f36dd5 --- /dev/null +++ b/packages/ui/src/badge.tsx @@ -0,0 +1,37 @@ +import type { VariantProps } from "class-variance-authority"; +import * as React from "react"; +import { cva } from "class-variance-authority"; + +import { cn } from "@acme/ui"; + +const badgeVariants = cva( + "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", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80", + secondary: + "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", + destructive: + "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80", + outline: "text-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ); +} + +export { Badge, badgeVariants }; diff --git a/packages/ui/src/button.tsx b/packages/ui/src/button.tsx new file mode 100644 index 0000000..6f5c3c6 --- /dev/null +++ b/packages/ui/src/button.tsx @@ -0,0 +1,58 @@ +import type { VariantProps } from "class-variance-authority"; +import * as React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { cva } from "class-variance-authority"; + +import { cn } from "@acme/ui"; + +const buttonVariants = cva( + "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50", + { + variants: { + variant: { + primary: + "bg-primary text-primary-foreground shadow hover:bg-primary/90", + destructive: + "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", + outline: + "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", + secondary: + "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + sm: "h-8 rounded-md px-3 text-xs", + md: "h-9 px-4 py-2", + lg: "h-10 rounded-md px-8", + icon: "size-9", + }, + }, + defaultVariants: { + variant: "primary", + size: "md", + }, + }, +); + +interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean; +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button"; + return ( + + ); + }, +); +Button.displayName = "Button"; + +export { Button, buttonVariants }; diff --git a/packages/ui/src/calendar.tsx b/packages/ui/src/calendar.tsx new file mode 100644 index 0000000..38d9e32 --- /dev/null +++ b/packages/ui/src/calendar.tsx @@ -0,0 +1,73 @@ +"use client"; + +import * as React from "react"; +import { ChevronLeftIcon, ChevronRightIcon } from "@radix-ui/react-icons"; +import { DayPicker } from "react-day-picker"; + +import { cn } from "@acme/ui"; + +import { buttonVariants } from "./button"; + +export type CalendarProps = React.ComponentProps; + +function Calendar({ + className, + classNames, + showOutsideDays = true, + ...props +}: CalendarProps) { + return ( + .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" + : "[&:has([aria-selected])]:rounded-md", + ), + day: cn( + buttonVariants({ variant: "ghost" }), + "h-8 w-8 p-0 font-normal aria-selected:opacity-100", + ), + day_range_start: "day-range-start", + day_range_end: "day-range-end", + day_selected: + "bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground", + day_today: "bg-accent text-accent-foreground", + day_outside: + "day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30", + day_disabled: "text-muted-foreground opacity-50", + day_range_middle: + "aria-selected:bg-accent aria-selected:text-accent-foreground", + day_hidden: "invisible", + ...classNames, + }} + components={{ + IconLeft: () => , + IconRight: () => , + }} + {...props} + /> + ); +} +Calendar.displayName = "Calendar"; + +export { Calendar }; diff --git a/packages/ui/src/dialog.tsx b/packages/ui/src/dialog.tsx new file mode 100644 index 0000000..5e0eaeb --- /dev/null +++ b/packages/ui/src/dialog.tsx @@ -0,0 +1,122 @@ +"use client"; + +import * as React from "react"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { Cross2Icon } from "@radix-ui/react-icons"; + +import { cn } from "@acme/ui"; + +const Dialog = DialogPrimitive.Root; + +const DialogTrigger = DialogPrimitive.Trigger; + +const DialogPortal = DialogPrimitive.Portal; + +const DialogClose = DialogPrimitive.Close; + +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)); +DialogContent.displayName = DialogPrimitive.Content.displayName; + +const DialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +DialogHeader.displayName = "DialogHeader"; + +const DialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +DialogFooter.displayName = "DialogFooter"; + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogTitle.displayName = DialogPrimitive.Title.displayName; + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogDescription.displayName = DialogPrimitive.Description.displayName; + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogTrigger, + DialogClose, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +}; diff --git a/packages/ui/src/dropdown-menu.tsx b/packages/ui/src/dropdown-menu.tsx new file mode 100644 index 0000000..ecca776 --- /dev/null +++ b/packages/ui/src/dropdown-menu.tsx @@ -0,0 +1,200 @@ +"use client"; + +import * as React from "react"; +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; +import { + CheckIcon, + ChevronRightIcon, + DotFilledIcon, +} from "@radix-ui/react-icons"; + +import { cn } from "@acme/ui"; + +const DropdownMenu = DropdownMenuPrimitive.Root; +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; +const DropdownMenuGroup = DropdownMenuPrimitive.Group; +const DropdownMenuPortal = DropdownMenuPrimitive.Portal; +const DropdownMenuSub = DropdownMenuPrimitive.Sub; +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; + +const DropdownMenuSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)); +DropdownMenuSubTrigger.displayName = + DropdownMenuPrimitive.SubTrigger.displayName; + +const DropdownMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuSubContent.displayName = + DropdownMenuPrimitive.SubContent.displayName; + +const DropdownMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)); +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; + +const DropdownMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + +)); +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; + +const DropdownMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)); +DropdownMenuCheckboxItem.displayName = + DropdownMenuPrimitive.CheckboxItem.displayName; + +const DropdownMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)); +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName; + +const DropdownMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + +)); +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; + +const DropdownMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; + +const DropdownMenuShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ); +}; +DropdownMenuShortcut.displayName = "DropdownMenuShortcut"; + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuGroup, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuRadioGroup, +}; diff --git a/packages/ui/src/form.tsx b/packages/ui/src/form.tsx new file mode 100644 index 0000000..e5cf58f --- /dev/null +++ b/packages/ui/src/form.tsx @@ -0,0 +1,201 @@ +"use client"; + +import type * as LabelPrimitive from "@radix-ui/react-label"; +import type { + ControllerProps, + FieldPath, + FieldValues, + UseFormProps, +} from "react-hook-form"; +import type { ZodType, ZodTypeDef } from "zod"; +import * as React from "react"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Slot } from "@radix-ui/react-slot"; +import { + useForm as __useForm, + Controller, + FormProvider, + useFormContext, +} from "react-hook-form"; + +import { cn } from "@acme/ui"; + +import { Label } from "./label"; + +const useForm = ( + props: Omit, "resolver"> & { + schema: ZodType; + }, +) => { + const form = __useForm({ + ...props, + resolver: zodResolver(props.schema, undefined), + }); + + return form; +}; + +const Form = FormProvider; + +interface FormFieldContextValue< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +> { + name: TName; +} + +const FormFieldContext = React.createContext( + null, +); + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +>({ + ...props +}: ControllerProps) => { + return ( + + + + ); +}; + +const useFormField = () => { + const fieldContext = React.useContext(FormFieldContext); + const itemContext = React.useContext(FormItemContext); + const { getFieldState, formState } = useFormContext(); + + if (!fieldContext) { + throw new Error("useFormField should be used within "); + } + const fieldState = getFieldState(fieldContext.name, formState); + + const { id } = itemContext; + + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState, + }; +}; + +interface FormItemContextValue { + id: string; +} + +const FormItemContext = React.createContext( + {} as FormItemContextValue, +); + +const FormItem = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + const id = React.useId(); + + return ( + +
+ + ); +}); +FormItem.displayName = "FormItem"; + +const FormLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + const { error, formItemId } = useFormField(); + + return ( +