feat(create-turbo): create https://github.com/juliusmarminge/acme-corp
This commit is contained in:
24
.env.example
Normal file
24
.env.example
Normal file
@@ -0,0 +1,24 @@
|
||||
# Since .env.local is gitignored, you can use .env.example to build a new `.env` file when you clone the repo.
|
||||
# Keep this file up-to-date when you add new variables to \`.env\`.
|
||||
|
||||
# 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.local file
|
||||
# @see https://www.prisma.io/docs/reference/database-reference/connection-urls
|
||||
DATABASE_URL='mysql://user:password@host/db?sslaccept=strict'
|
||||
|
||||
# Clerk
|
||||
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY="pk_test_"
|
||||
CLERK_SECRET_KEY="sk_test_"
|
||||
|
||||
# Stripe
|
||||
STRIPE_API_KEY="sk_test_"
|
||||
STRIPE_WEBHOOK_SECRET="whsec_"
|
||||
NEXT_PUBLIC_STRIPE_STD_PRODUCT_ID="prod_"
|
||||
NEXT_PUBLIC_STRIPE_STD_MONTHLY_PRICE_ID="price_"
|
||||
NEXT_PUBLIC_STRIPE_PRO_PRODUCT_ID="prod_"
|
||||
NEXT_PUBLIC_STRIPE_PRO_MONTHLY_PRICE_ID="price_"
|
||||
|
||||
# Misc
|
||||
NEXTJS_URL="http://localhost:3000"
|
||||
3
.github/FUNDING.yml
vendored
Normal file
3
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: juliusmarminge
|
||||
37
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
37
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
name: 🐞 Bug Report
|
||||
description: Create a bug report to help us improve
|
||||
title: "bug: "
|
||||
labels: ["🐞❔ unconfirmed bug"]
|
||||
body:
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Provide environment information
|
||||
description: |
|
||||
Run this command in your project root and paste the results in a code block:
|
||||
```bash
|
||||
npx envinfo --system --binaries
|
||||
```
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Describe the bug
|
||||
description: A clear and concise description of the bug, as well as what you expected to happen when encountering it.
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: Link to reproduction
|
||||
description: Please provide a link to a reproduction of the bug. Issues without a reproduction repo may be ignored.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: To reproduce
|
||||
description: Describe how to reproduce your bug. Steps, code snippets, reproduction repos etc.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Additional information
|
||||
description: Add any other information related to the bug here, screenshots if applicable.
|
||||
29
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
29
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
# This template is heavily inspired by the Next.js's template:
|
||||
# See here: https://github.com/vercel/next.js/blob/canary/.github/ISSUE_TEMPLATE/3.feature_request.yml
|
||||
|
||||
name: 🛠 Feature Request
|
||||
description: Create a feature request for the core packages
|
||||
title: 'feat: '
|
||||
labels: ['✨ enhancement']
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thank you for taking the time to file a feature request. Please fill out this form as completely as possible.
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Describe the feature you'd like to request
|
||||
description: Please describe the feature as clear and concise as possible. Remember to add context as to why you believe this feature is needed.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Describe the solution you'd like to see
|
||||
description: Please describe the solution you would like to see. Adding example usage is a good way to provide context.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Additional information
|
||||
description: Add any other information related to the feature here. If your feature request is related to any issues or discussions, link them here.
|
||||
|
||||
12
.github/renovate.json
vendored
Normal file
12
.github/renovate.json
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": ["config:base"],
|
||||
"packageRules": [
|
||||
{
|
||||
"matchPackagePatterns": ["^@acme/"],
|
||||
"enabled": false
|
||||
}
|
||||
],
|
||||
"updateInternalDeps": true,
|
||||
"rangeStrategy": "bump"
|
||||
}
|
||||
56
.github/workflows/ci.yml
vendored
Normal file
56
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,56 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: ["*"]
|
||||
push:
|
||||
branches: ["main"]
|
||||
merge_group:
|
||||
|
||||
# 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:
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
||||
|
||||
jobs:
|
||||
build-lint:
|
||||
env:
|
||||
DATABASE_URL: file:./db.sqlite
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v2.4.0
|
||||
|
||||
- name: Setup Node 20
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- 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@v4
|
||||
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
|
||||
|
||||
- run: cp .env.example .env.local
|
||||
|
||||
- name: Build, lint and type-check
|
||||
run: pnpm turbo build lint typecheck format
|
||||
|
||||
- name: Check workspaces
|
||||
run: pnpm manypkg check
|
||||
49
.gitignore
vendored
Normal file
49
.gitignore
vendored
Normal file
@@ -0,0 +1,49 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
node_modules
|
||||
.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
coverage
|
||||
|
||||
# database
|
||||
**/prisma/db.sqlite
|
||||
**/prisma/db.sqlite-journal
|
||||
|
||||
# next.js
|
||||
.next/
|
||||
out/
|
||||
next-env.d.ts
|
||||
|
||||
# expo
|
||||
.expo/
|
||||
dist/
|
||||
expo-env.d.ts
|
||||
|
||||
# production
|
||||
build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# local env files
|
||||
.env
|
||||
.env*.local
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
|
||||
# turbo
|
||||
.turbo
|
||||
18
.npmrc
Normal file
18
.npmrc
Normal file
@@ -0,0 +1,18 @@
|
||||
# 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
|
||||
|
||||
# Prevent pnpm from adding the "workspace:"" prefix to local
|
||||
# packages as it casues issues with manypkg
|
||||
# @link https://pnpm.io/npmrc#prefer-workspace-packages
|
||||
save-workspace-protocol=false
|
||||
prefer-workspace-packages=true
|
||||
8
.vscode/extensions.json
vendored
Normal file
8
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"esbenp.prettier-vscode",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"bradlc.vscode-tailwindcss",
|
||||
"Prisma.prisma"
|
||||
]
|
||||
}
|
||||
13
.vscode/launch.json
vendored
Normal file
13
.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Next.js",
|
||||
"type": "node-terminal",
|
||||
"request": "launch",
|
||||
"command": "pnpm dev",
|
||||
"cwd": "${workspaceFolder}/apps/nextjs/",
|
||||
"skipFiles": ["<node_internals>/**"]
|
||||
}
|
||||
]
|
||||
}
|
||||
23
.vscode/settings.json
vendored
Normal file
23
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"[prisma]": {
|
||||
"editor.defaultFormatter": "Prisma.prisma"
|
||||
},
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "explicit"
|
||||
},
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true,
|
||||
"eslint.rules.customizations": [{ "rule": "*", "severity": "warn" }],
|
||||
"eslint.workingDirectories": [
|
||||
{ "pattern": "apps/*/" },
|
||||
{ "pattern": "packages/*/" },
|
||||
{ "pattern": "tooling/*/" }
|
||||
],
|
||||
"tailwindCSS.experimental.configFile": "./tooling/tailwind/index.ts",
|
||||
"typescript.tsdk": "node_modules/typescript/lib",
|
||||
"typescript.enablePromptUseWorkspaceTsdk": true,
|
||||
"typescript.preferences.autoImportFileExcludePatterns": [
|
||||
"next/router.d.ts",
|
||||
"next/dist/client/router.d.ts"
|
||||
]
|
||||
}
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2023 Julius Marminge
|
||||
|
||||
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.
|
||||
96
README.md
Normal file
96
README.md
Normal file
@@ -0,0 +1,96 @@
|
||||
# Acme Corp
|
||||
|
||||
> **Warning**
|
||||
> This is a work-in-progress and not the finished product.
|
||||
>
|
||||
> Feel free to leave feature suggestions but please don't open issues for bugs or support requests just yet.
|
||||
|
||||
## About
|
||||
|
||||
This project features the next-generation stack for building fullstack application. It's structured as a monorepo with a shared API using tRPC. Built on the new app router in Next.js 13 with React Server Components.
|
||||
|
||||
- For database querying, [Kysely](https://kysely.dev) is used as a query builder whilst remaining [Prisma](https://prisma.io) as a schema management tool. (This means it's fully edge-ready!). To keep a good DX, we use a custom setup with kysely-prisma-generator to pull out all the prisma types, and then a [post-generate script](./packages/db/prisma/postgenerate.ts) to create a fully typesafe db client using database.js from [PlanetScale](https://planetscale.com).
|
||||
- This project uses [Clerk](https://clerk.com) as it's authentication provider.
|
||||
- Awesome UI components from [shadcn/ui](https://ui.shadcn.com)
|
||||
|
||||
## Installation
|
||||
|
||||
There are two ways of initializing an app using the `acme-corp` starter. You can either use this repository as a template:
|
||||
|
||||

|
||||
|
||||
or use Turbo's CLI to init your project:
|
||||
|
||||
```bash
|
||||
npx create-turbo@latest -e https://github.com/juliusmarminge/acme-corp
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
> **Note**
|
||||
> The [db](./packages/db) package is preconfigured to use PlanetScale and is edge-ready with the [database.js](https://github.com/planetscale/database-js) driver. If you're using something else, make the necesary modifications to the [Kysely client](./packages/db/index.ts).
|
||||
|
||||
To get it running, follow the steps below:
|
||||
|
||||
### 1. Setup dependencies
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
pnpm i
|
||||
|
||||
# Configure environment variables
|
||||
# There is an `.env.example` in the root directory you can use for reference
|
||||
cp .env.example .env.local
|
||||
|
||||
# Push the Prisma schema to the database
|
||||
pnpm db:push
|
||||
```
|
||||
|
||||
### 2. Configure Expo `dev`-script
|
||||
|
||||
> **Warning**
|
||||
> The Expo app is still stock from `create-t3-turbo` and haven't yet gotten any attention.
|
||||
>
|
||||
> We will get their in due time!
|
||||
|
||||
#### 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. Run `npx expo start` in the root dir, and then enter `I` to launch Expo Go. After the manual launch, you can run `pnpm dev` in the root directory.
|
||||
|
||||
```diff
|
||||
+ "dev": "expo start --ios",
|
||||
```
|
||||
|
||||
2. Run `pnpm dev` at the project root folder.
|
||||
|
||||
#### Use Android Emulator
|
||||
|
||||
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.
|
||||
|
||||
> **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.
|
||||
|
||||
### 3. When it's time to add a new package
|
||||
|
||||
To add a new package, simply run `pnpm turbo gen init` in the monorepo root. This will prompt you for a package name as well as if you want to install any dependencies to the new package (of course you can also do this yourself later).
|
||||
|
||||
The generator sets up the `package.json`, `tsconfig.json` and a `index.ts`, as well as configures all the necessary configurations for tooling around your package such as formatting, linting and typechecking. When the package is created, you're ready to go build out the package.
|
||||
|
||||
## 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.
|
||||
|
||||
## Questions?
|
||||
|
||||
<a href="https://cal.com/julius/quick-chat?utm_source=banner&utm_campaign=oss"><img alt="Book us with Cal.com" src="https://cal.com/book-with-cal-dark.svg" /></a>
|
||||
4
apps/expo/.expo-shared/assets.json
Normal file
4
apps/expo/.expo-shared/assets.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"12bb71342c6255bbf50437ec8f4441c083f47cdb74bd89160c15e4f43e52a1cb": true,
|
||||
"40b842e832070c58deac6aa9e08fa459302ee3f9da492c7e77d93d2fbf4a56fd": true
|
||||
}
|
||||
43
apps/expo/app.config.ts
Normal file
43
apps/expo/app.config.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { ExpoConfig } from "@expo/config";
|
||||
|
||||
const defineConfig = (): ExpoConfig => ({
|
||||
name: "expo",
|
||||
slug: "expo",
|
||||
scheme: "expo",
|
||||
version: "0.1.0",
|
||||
orientation: "portrait",
|
||||
icon: "./assets/icon.png",
|
||||
userInterfaceStyle: "light",
|
||||
splash: {
|
||||
image: "./assets/icon.png",
|
||||
resizeMode: "contain",
|
||||
backgroundColor: "#1F104A",
|
||||
},
|
||||
updates: {
|
||||
fallbackToCacheTimeout: 0,
|
||||
},
|
||||
assetBundlePatterns: ["**/*"],
|
||||
ios: {
|
||||
bundleIdentifier: "your.bundle.identifier",
|
||||
supportsTablet: true,
|
||||
},
|
||||
android: {
|
||||
package: "your.bundle.identifier",
|
||||
adaptiveIcon: {
|
||||
foregroundImage: "./assets/icon.png",
|
||||
backgroundColor: "#1F104A",
|
||||
},
|
||||
},
|
||||
// extra: {
|
||||
// eas: {
|
||||
// projectId: "your-eas-project-id",
|
||||
// },
|
||||
// },
|
||||
experiments: {
|
||||
tsconfigPaths: true,
|
||||
typedRoutes: true,
|
||||
},
|
||||
plugins: ["expo-router", "./expo-plugins/with-modify-gradle.js"],
|
||||
});
|
||||
|
||||
export default defineConfig;
|
||||
BIN
apps/expo/assets/icon.png
Normal file
BIN
apps/expo/assets/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
15
apps/expo/babel.config.js
Normal file
15
apps/expo/babel.config.js
Normal file
@@ -0,0 +1,15 @@
|
||||
/** @type {import("@babel/core").ConfigFunction} */
|
||||
module.exports = function (api) {
|
||||
api.cache.forever();
|
||||
|
||||
return {
|
||||
presets: [
|
||||
["babel-preset-expo", { jsxImportSource: "nativewind" }],
|
||||
"nativewind/babel",
|
||||
],
|
||||
plugins: [
|
||||
require.resolve("expo-router/babel"),
|
||||
require.resolve("react-native-reanimated/plugin"),
|
||||
],
|
||||
};
|
||||
};
|
||||
31
apps/expo/eas.json
Normal file
31
apps/expo/eas.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"cli": {
|
||||
"version": ">= 4.1.2"
|
||||
},
|
||||
"build": {
|
||||
"base": {
|
||||
"node": "18.16.1",
|
||||
"ios": {
|
||||
"resourceClass": "m-medium"
|
||||
}
|
||||
},
|
||||
"development": {
|
||||
"extends": "base",
|
||||
"developmentClient": true,
|
||||
"distribution": "internal"
|
||||
},
|
||||
"preview": {
|
||||
"extends": "base",
|
||||
"distribution": "internal",
|
||||
"ios": {
|
||||
"simulator": true
|
||||
}
|
||||
},
|
||||
"production": {
|
||||
"extends": "base"
|
||||
}
|
||||
},
|
||||
"submit": {
|
||||
"production": {}
|
||||
}
|
||||
}
|
||||
44
apps/expo/expo-plugins/with-modify-gradle.js
Normal file
44
apps/expo/expo-plugins/with-modify-gradle.js
Normal file
@@ -0,0 +1,44 @@
|
||||
// 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} */
|
||||
const defineConfig = (config) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
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;
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
module.exports = defineConfig;
|
||||
29
apps/expo/metro.config.js
Normal file
29
apps/expo/metro.config.js
Normal file
@@ -0,0 +1,29 @@
|
||||
// Learn more: https://docs.expo.dev/guides/monorepos/
|
||||
const { getDefaultConfig } = require("@expo/metro-config");
|
||||
const { withNativeWind } = require("nativewind/metro");
|
||||
|
||||
const path = require("path");
|
||||
|
||||
const projectRoot = __dirname;
|
||||
const workspaceRoot = path.resolve(projectRoot, "../..");
|
||||
|
||||
// Create the default Metro config
|
||||
const config = getDefaultConfig(projectRoot, { isCSSEnabled: true });
|
||||
|
||||
if (config.resolver) {
|
||||
// 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;
|
||||
}
|
||||
|
||||
// @ts-expect-error - FIXME: type is mismatching?
|
||||
module.exports = withNativeWind(config, {
|
||||
input: "./src/styles.css",
|
||||
configPath: "./tailwind.config.ts",
|
||||
});
|
||||
68
apps/expo/package.json
Normal file
68
apps/expo/package.json
Normal file
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"name": "@acme/expo",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"main": "expo-router/entry",
|
||||
"scripts": {
|
||||
"clean": "git clean -xdf .expo .turbo node_modules",
|
||||
"dev": "expo start --ios",
|
||||
"dev:android": "expo start --android",
|
||||
"dev:ios": "expo start --ios",
|
||||
"android": "expo run:android",
|
||||
"ios": "expo run:ios",
|
||||
"format": "prettier --check . --ignore-path ../../.gitignore",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/metro-config": "^0.10.7",
|
||||
"@shopify/flash-list": "1.4.3",
|
||||
"@tanstack/react-query": "^5.17.15",
|
||||
"@trpc/client": "next",
|
||||
"@trpc/react-query": "next",
|
||||
"@trpc/server": "next",
|
||||
"expo": "^49.0.22",
|
||||
"expo-constants": "~14.4.2",
|
||||
"expo-linking": "~5.0.2",
|
||||
"expo-router": "2.0.14",
|
||||
"expo-splash-screen": "~0.22.0",
|
||||
"expo-status-bar": "~1.7.1",
|
||||
"nativewind": "^4.0.23",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-native": "0.73.1",
|
||||
"react-native-gesture-handler": "~2.12.0",
|
||||
"react-native-reanimated": "~3.3.0",
|
||||
"react-native-safe-area-context": "4.6.3",
|
||||
"react-native-screens": "~3.22.1",
|
||||
"superjson": "2.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@acme/api": "0.1.0",
|
||||
"@acme/eslint-config": "0.2.0",
|
||||
"@acme/prettier-config": "0.1.0",
|
||||
"@acme/tailwind-config": "0.1.0",
|
||||
"@acme/tsconfig": "0.1.0",
|
||||
"@babel/core": "^7.23.7",
|
||||
"@babel/preset-env": "^7.23.8",
|
||||
"@babel/runtime": "^7.23.8",
|
||||
"@expo/config-plugins": "^7.8.4",
|
||||
"@types/babel__core": "^7.20.5",
|
||||
"@types/react": "^18.2.48",
|
||||
"eslint": "^8.56.0",
|
||||
"prettier": "^3.2.4",
|
||||
"tailwindcss": "3.4.1",
|
||||
"typescript": "^5.3.3"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"root": true,
|
||||
"extends": [
|
||||
"@acme/eslint-config/base",
|
||||
"@acme/eslint-config/react"
|
||||
],
|
||||
"ignorePatterns": [
|
||||
"expo-plugins/**"
|
||||
]
|
||||
},
|
||||
"prettier": "@acme/prettier-config"
|
||||
}
|
||||
27
apps/expo/src/app/_layout.tsx
Normal file
27
apps/expo/src/app/_layout.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Stack } from "expo-router";
|
||||
import { StatusBar } from "expo-status-bar";
|
||||
|
||||
import { TRPCProvider } from "~/utils/api";
|
||||
|
||||
import "../styles.css";
|
||||
|
||||
// This is the main layout of the app
|
||||
// It wraps your pages with the providers they need
|
||||
export default function RootLayout() {
|
||||
return (
|
||||
<TRPCProvider>
|
||||
{/*
|
||||
The Stack component displays the current page.
|
||||
It also allows you to configure your screens
|
||||
*/}
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerStyle: {
|
||||
backgroundColor: "#f472b6",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<StatusBar />
|
||||
</TRPCProvider>
|
||||
);
|
||||
}
|
||||
145
apps/expo/src/app/index.tsx
Normal file
145
apps/expo/src/app/index.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
// import { useState } from "react";
|
||||
// import { Button, Pressable, Text, TextInput, View } from "react-native";
|
||||
// import { SafeAreaView } from "react-native-safe-area-context";
|
||||
// import { Link, Stack } from "expo-router";
|
||||
// import { FlashList } from "@shopify/flash-list";
|
||||
|
||||
// import type { RouterOutputs } from "~/utils/api";
|
||||
// import { api } from "~/utils/api";
|
||||
|
||||
// function PostCard(props: {
|
||||
// post: RouterOutputs["post"]["all"][number];
|
||||
// onDelete: () => void;
|
||||
// }) {
|
||||
// return (
|
||||
// <View className="flex flex-row rounded-lg bg-white/10 p-4">
|
||||
// <View className="flex-grow">
|
||||
// <Link
|
||||
// asChild
|
||||
// href={{
|
||||
// pathname: "/post/[id]",
|
||||
// params: { id: props.post.id },
|
||||
// }}
|
||||
// >
|
||||
// <Pressable>
|
||||
// <Text className="text-xl font-semibold text-pink-400">
|
||||
// {props.post.title}
|
||||
// </Text>
|
||||
// <Text className="mt-2 text-white">{props.post.content}</Text>
|
||||
// </Pressable>
|
||||
// </Link>
|
||||
// </View>
|
||||
// <Pressable onPress={props.onDelete}>
|
||||
// <Text className="font-bold uppercase text-pink-400">Delete</Text>
|
||||
// </Pressable>
|
||||
// </View>
|
||||
// );
|
||||
// }
|
||||
|
||||
// function CreatePost() {
|
||||
// const utils = api.useUtils();
|
||||
|
||||
// const [title, setTitle] = useState("");
|
||||
// const [content, setContent] = useState("");
|
||||
|
||||
// const { mutate, error } = api.post.create.useMutation({
|
||||
// async onSuccess() {
|
||||
// setTitle("");
|
||||
// setContent("");
|
||||
// await utils.post.all.invalidate();
|
||||
// },
|
||||
// });
|
||||
|
||||
// return (
|
||||
// <View className="mt-4">
|
||||
// <TextInput
|
||||
// className="mb-2 rounded bg-white/10 p-2 text-white"
|
||||
// placeholderTextColor="rgba(255, 255, 255, 0.5)"
|
||||
// value={title}
|
||||
// onChangeText={setTitle}
|
||||
// placeholder="Title"
|
||||
// />
|
||||
// {error?.data?.zodError?.fieldErrors.title && (
|
||||
// <Text className="mb-2 text-red-500">
|
||||
// {error.data.zodError.fieldErrors.title}
|
||||
// </Text>
|
||||
// )}
|
||||
// <TextInput
|
||||
// className="mb-2 rounded bg-white/10 p-2 text-white"
|
||||
// placeholderTextColor="rgba(255, 255, 255, 0.5)"
|
||||
// value={content}
|
||||
// onChangeText={setContent}
|
||||
// placeholder="Content"
|
||||
// />
|
||||
// {error?.data?.zodError?.fieldErrors.content && (
|
||||
// <Text className="mb-2 text-red-500">
|
||||
// {error.data.zodError.fieldErrors.content}
|
||||
// </Text>
|
||||
// )}
|
||||
// <Pressable
|
||||
// className="rounded bg-pink-400 p-2"
|
||||
// onPress={() => {
|
||||
// mutate({
|
||||
// title,
|
||||
// content,
|
||||
// });
|
||||
// }}
|
||||
// >
|
||||
// <Text className="font-semibold text-white">Publish post</Text>
|
||||
// </Pressable>
|
||||
// {error?.data?.code === "UNAUTHORIZED" && (
|
||||
// <Text className="mt-2 text-red-500">
|
||||
// You need to be logged in to create a post
|
||||
// </Text>
|
||||
// )}
|
||||
// </View>
|
||||
// );
|
||||
// }
|
||||
|
||||
// export default function Index() {
|
||||
// const utils = api.useUtils();
|
||||
|
||||
// const postQuery = api.post.all.useQuery();
|
||||
|
||||
// const deletePostMutation = api.post.delete.useMutation({
|
||||
// onSettled: () => utils.post.all.invalidate(),
|
||||
// });
|
||||
|
||||
// return (
|
||||
// <SafeAreaView className="bg-[#1F104A]">
|
||||
// {/* Changes page title visible on the header */}
|
||||
// <Stack.Screen options={{ title: "Home Page" }} />
|
||||
// <View className="h-full w-full p-4">
|
||||
// <Text className="pb-2 text-center text-5xl font-bold text-white">
|
||||
// Create <Text className="text-pink-400">T3</Text> Turbo
|
||||
// </Text>
|
||||
|
||||
// <Button
|
||||
// onPress={() => void utils.post.all.invalidate()}
|
||||
// title="Refresh posts"
|
||||
// color={"#f472b6"}
|
||||
// />
|
||||
|
||||
// <View className="py-2">
|
||||
// <Text className="font-semibold italic text-white">
|
||||
// Press on a post
|
||||
// </Text>
|
||||
// </View>
|
||||
|
||||
// <FlashList
|
||||
// data={postQuery.data}
|
||||
// estimatedItemSize={20}
|
||||
// ItemSeparatorComponent={() => <View className="h-2" />}
|
||||
// renderItem={(p) => (
|
||||
// <PostCard
|
||||
// post={p.item}
|
||||
// onDelete={() => deletePostMutation.mutate(p.item.id)}
|
||||
// />
|
||||
// )}
|
||||
// />
|
||||
|
||||
// <CreatePost />
|
||||
// </View>
|
||||
// </SafeAreaView>
|
||||
// );
|
||||
// }
|
||||
22
apps/expo/src/app/post/[id].tsx
Normal file
22
apps/expo/src/app/post/[id].tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
// import { SafeAreaView, Text, View } from "react-native";
|
||||
// import { Stack, useGlobalSearchParams } from "expo-router";
|
||||
|
||||
// import { api } from "~/utils/api";
|
||||
|
||||
// export default function Post() {
|
||||
// const { id } = useGlobalSearchParams();
|
||||
// if (!id || typeof id !== "string") throw new Error("unreachable");
|
||||
// const { data } = api.post.byId.useQuery({ id: parseInt(id) });
|
||||
|
||||
// if (!data) return null;
|
||||
|
||||
// return (
|
||||
// <SafeAreaView className="bg-[#1F104A]">
|
||||
// <Stack.Screen options={{ title: data.title }} />
|
||||
// <View className="h-full w-full p-4">
|
||||
// <Text className="py-2 text-3xl font-bold text-white">{data.title}</Text>
|
||||
// <Text className="py-4 text-white">{data.content}</Text>
|
||||
// </View>
|
||||
// </SafeAreaView>
|
||||
// );
|
||||
// }
|
||||
3
apps/expo/src/styles.css
Normal file
3
apps/expo/src/styles.css
Normal file
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
1
apps/expo/src/types/nativewind-env.d.ts
vendored
Normal file
1
apps/expo/src/types/nativewind-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="nativewind/types" />
|
||||
76
apps/expo/src/utils/api.tsx
Normal file
76
apps/expo/src/utils/api.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { useState } from "react";
|
||||
import Constants from "expo-constants";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { httpBatchLink, loggerLink } from "@trpc/client";
|
||||
import { createTRPCReact } from "@trpc/react-query";
|
||||
import superjson from "superjson";
|
||||
|
||||
import type { AppRouter } from "@acme/api";
|
||||
|
||||
/**
|
||||
* A set of typesafe hooks for consuming your API.
|
||||
*/
|
||||
export const api = createTRPCReact<AppRouter>();
|
||||
export { type RouterInputs, type RouterOutputs } from "@acme/api";
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* **NOTE**: This is only for development. In production, you'll want to set the
|
||||
* baseUrl to your production API URL.
|
||||
*/
|
||||
const debuggerHost = Constants.expoConfig?.hostUri;
|
||||
const localhost = debuggerHost?.split(":")[0];
|
||||
|
||||
if (!localhost) {
|
||||
// return "https://turbo.t3.gg";
|
||||
throw new Error(
|
||||
"Failed to get localhost. Please point to your production server.",
|
||||
);
|
||||
}
|
||||
return `http://${localhost}:3000`;
|
||||
};
|
||||
|
||||
/**
|
||||
* A wrapper for your app that provides the TRPC context.
|
||||
* Use only in _app.tsx
|
||||
*/
|
||||
export function TRPCProvider(props: { children: React.ReactNode }) {
|
||||
const [queryClient] = useState(() => new QueryClient());
|
||||
const [trpcClient] = useState(() =>
|
||||
api.createClient({
|
||||
transformer: superjson, // TODO: Add transforming for Dinero.js
|
||||
links: [
|
||||
httpBatchLink({
|
||||
url: `${getBaseUrl()}/api/trpc`,
|
||||
headers() {
|
||||
const headers = new Map<string, string>();
|
||||
headers.set("x-trpc-source", "expo-react");
|
||||
return Object.fromEntries(headers);
|
||||
},
|
||||
}),
|
||||
loggerLink({
|
||||
enabled: (opts) =>
|
||||
process.env.NODE_ENV === "development" ||
|
||||
(opts.direction === "down" && opts.result instanceof Error),
|
||||
colorMode: "ansi",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
<api.Provider client={trpcClient} queryClient={queryClient}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{props.children}
|
||||
</QueryClientProvider>
|
||||
</api.Provider>
|
||||
);
|
||||
}
|
||||
10
apps/expo/tailwind.config.ts
Normal file
10
apps/expo/tailwind.config.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
// @ts-expect-error - no types
|
||||
import nativewind from "nativewind/preset";
|
||||
import type { Config } from "tailwindcss";
|
||||
|
||||
import baseConfig from "@acme/tailwind-config";
|
||||
|
||||
export default {
|
||||
content: ["./src/**/*.{ts,tsx}"],
|
||||
presets: [baseConfig, nativewind],
|
||||
} satisfies Config;
|
||||
21
apps/expo/tsconfig.json
Normal file
21
apps/expo/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"extends": "@acme/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"~/*": ["./src/*"],
|
||||
},
|
||||
"jsx": "react-native",
|
||||
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json",
|
||||
"types": ["nativewind/types"],
|
||||
},
|
||||
"include": [
|
||||
"src",
|
||||
"*.ts",
|
||||
"index.tsx",
|
||||
"*.js",
|
||||
".expo/types/**/*.ts",
|
||||
"expo-env.d.ts",
|
||||
],
|
||||
"exclude": ["node_modules"],
|
||||
}
|
||||
4
apps/nextjs/.vscode/settings.json
vendored
Normal file
4
apps/nextjs/.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"typescript.tsdk": "../../node_modules/typescript/lib",
|
||||
"typescript.enablePromptUseWorkspaceTsdk": true
|
||||
}
|
||||
28
apps/nextjs/README.md
Normal file
28
apps/nextjs/README.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# Create T3 App
|
||||
|
||||
This is a [T3 Stack](https://create.t3.gg/) project bootstrapped with `create-t3-app`.
|
||||
|
||||
## What's next? How do I make an app with this?
|
||||
|
||||
We try to keep this project as simple as possible, so you can start with just the scaffolding we set up for you, and add additional things later when they become necessary.
|
||||
|
||||
If you are not familiar with the different technologies used in this project, please refer to the respective docs. If you still are in the wind, please join our [Discord](https://t3.gg/discord) and ask for help.
|
||||
|
||||
- [Next.js](https://nextjs.org)
|
||||
- [NextAuth.js](https://next-auth.js.org)
|
||||
- [Prisma](https://prisma.io)
|
||||
- [Tailwind CSS](https://tailwindcss.com)
|
||||
- [tRPC](https://trpc.io)
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about the [T3 Stack](https://create.t3.gg/), take a look at the following resources:
|
||||
|
||||
- [Documentation](https://create.t3.gg/)
|
||||
- [Learn the T3 Stack](https://create.t3.gg/en/faq#what-learning-resources-are-currently-available) — Check out these awesome tutorials
|
||||
|
||||
You can check out the [create-t3-app GitHub repository](https://github.com/t3-oss/create-t3-app) — your feedback and contributions are welcome!
|
||||
|
||||
## How do I deploy this?
|
||||
|
||||
Follow our deployment guides for [Vercel](https://create.t3.gg/en/deployment/vercel) and [Docker](https://create.t3.gg/en/deployment/docker) for more information.
|
||||
22
apps/nextjs/next.config.mjs
Normal file
22
apps/nextjs/next.config.mjs
Normal file
@@ -0,0 +1,22 @@
|
||||
import "./src/env.mjs";
|
||||
import "@acme/api/env";
|
||||
import "@acme/stripe/env";
|
||||
|
||||
import withMDX from "@next/mdx";
|
||||
|
||||
/** @type {import("next").NextConfig} */
|
||||
const config = {
|
||||
reactStrictMode: true,
|
||||
/** Enables hot reloading for local packages without a build step */
|
||||
transpilePackages: ["@acme/api", "@acme/db", "@acme/stripe", "@acme/ui"],
|
||||
pageExtensions: ["ts", "tsx", "mdx"],
|
||||
experimental: {
|
||||
mdxRs: true,
|
||||
},
|
||||
|
||||
/** We already do linting and typechecking as separate tasks in CI */
|
||||
eslint: { ignoreDuringBuilds: true },
|
||||
typescript: { ignoreBuildErrors: true },
|
||||
};
|
||||
|
||||
export default withMDX()(config);
|
||||
73
apps/nextjs/package.json
Normal file
73
apps/nextjs/package.json
Normal file
@@ -0,0 +1,73 @@
|
||||
{
|
||||
"name": "@acme/nextjs",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "pnpm with-env next build",
|
||||
"clean": "git clean -xdf .next .turbo node_modules",
|
||||
"dev": "pnpm with-env next dev",
|
||||
"lint": "next lint",
|
||||
"format": "prettier --check \"**/*.{js,cjs,mjs,ts,tsx,md,json}\"",
|
||||
"start": "pnpm with-env next start",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"with-env": "dotenv -e ../../.env.local --"
|
||||
},
|
||||
"dependencies": {
|
||||
"@acme/api": "^0.1.0",
|
||||
"@acme/db": "^0.1.0",
|
||||
"@acme/stripe": "^0.1.0",
|
||||
"@acme/ui": "^0.1.0",
|
||||
"@clerk/nextjs": "^4.29.4",
|
||||
"@dinero.js/currencies": "2.0.0-alpha.14",
|
||||
"@hookform/resolvers": "^3.3.4",
|
||||
"@next/mdx": "^14.1.0",
|
||||
"@t3-oss/env-nextjs": "^0.7.3",
|
||||
"@tanstack/react-query": "^5.17.15",
|
||||
"@tanstack/react-table": "^8.11.3",
|
||||
"@trpc/client": "next",
|
||||
"@trpc/next": "next",
|
||||
"@trpc/react-query": "next",
|
||||
"@trpc/server": "next",
|
||||
"@vercel/analytics": "^1.1.2",
|
||||
"date-fns": "^3.2.0",
|
||||
"dinero.js": "2.0.0-alpha.14",
|
||||
"framer-motion": "^10.18.0",
|
||||
"next": "^14.1.0",
|
||||
"next-themes": "^0.2.1",
|
||||
"react": "18.2.0",
|
||||
"react-day-picker": "^8.10.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-hook-form": "^7.49.2",
|
||||
"react-image-crop": "^11.0.4",
|
||||
"react-wrap-balancer": "^1.1.0",
|
||||
"recharts": "^2.10.3",
|
||||
"superjson": "2.2.1",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@acme/eslint-config": "^0.2.0",
|
||||
"@acme/prettier-config": "^0.1.0",
|
||||
"@acme/tailwind-config": "^0.1.0",
|
||||
"@acme/tsconfig": "^0.1.0",
|
||||
"@types/mdx": "^2.0.10",
|
||||
"@types/node": "^20.11.5",
|
||||
"@types/react": "^18.2.48",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"autoprefixer": "^10.4.17",
|
||||
"dotenv-cli": "^7.3.0",
|
||||
"eslint": "^8.56.0",
|
||||
"prettier": "^3.2.4",
|
||||
"tailwindcss": "3.4.1",
|
||||
"typescript": "^5.3.3"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"root": true,
|
||||
"extends": [
|
||||
"@acme/eslint-config/base",
|
||||
"@acme/eslint-config/nextjs",
|
||||
"@acme/eslint-config/react"
|
||||
]
|
||||
},
|
||||
"prettier": "@acme/prettier-config"
|
||||
}
|
||||
2
apps/nextjs/postcss.config.cjs
Normal file
2
apps/nextjs/postcss.config.cjs
Normal file
@@ -0,0 +1,2 @@
|
||||
// @ts-expect-error - No types for postcss
|
||||
module.exports = require("@acme/tailwind-config/postcss");
|
||||
BIN
apps/nextjs/public/favicon.ico
Normal file
BIN
apps/nextjs/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 203 KiB |
BIN
apps/nextjs/public/og-image.png
Normal file
BIN
apps/nextjs/public/og-image.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 313 KiB |
13
apps/nextjs/public/t3-icon.svg
Normal file
13
apps/nextjs/public/t3-icon.svg
Normal file
@@ -0,0 +1,13 @@
|
||||
<svg width="258" height="198" viewBox="0 0 258 198" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_1_12)">
|
||||
<path d="M165.269 24.0976L188.481 -0.000411987H0V24.0976H165.269Z" fill="black"/>
|
||||
<path d="M163.515 95.3516L253.556 2.71059H220.74L145.151 79.7886L163.515 95.3516Z" fill="black"/>
|
||||
<path d="M233.192 130.446C233.192 154.103 214.014 173.282 190.357 173.282C171.249 173.282 155.047 160.766 149.534 143.467L146.159 132.876L126.863 152.171L128.626 156.364C138.749 180.449 162.568 197.382 190.357 197.382C227.325 197.382 257.293 167.414 257.293 130.446C257.293 105.965 243.933 84.7676 224.49 73.1186L219.929 70.3856L202.261 88.2806L210.322 92.5356C223.937 99.7236 233.192 114.009 233.192 130.446Z" fill="black"/>
|
||||
<path d="M87.797 191.697V44.6736H63.699V191.697H87.797Z" fill="black"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1_12">
|
||||
<rect width="258" height="198" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 923 B |
37
apps/nextjs/src/app/(auth)/layout.tsx
Normal file
37
apps/nextjs/src/app/(auth)/layout.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import * as Icons from "@acme/ui/icons";
|
||||
|
||||
import { siteConfig } from "~/app/config";
|
||||
import { SiteFooter } from "~/components/footer";
|
||||
|
||||
export default function AuthLayout(props: { children: React.ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<div className="relative grid min-h-screen grid-cols-1 overflow-hidden md:grid-cols-3 lg:grid-cols-2">
|
||||
<div className="relative">
|
||||
<div
|
||||
className="absolute inset-0 bg-cover"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"url(https://images.unsplash.com/photo-1590069261209-f8e9b8642343?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1376&q=80)",
|
||||
}}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-background to-background/60 md:to-background/40" />
|
||||
<Link
|
||||
href="/"
|
||||
className="absolute left-8 top-8 z-20 flex items-center text-lg font-bold tracking-tight"
|
||||
>
|
||||
<Icons.Logo className="mr-2 h-6 w-6" />
|
||||
<span>{siteConfig.name}</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="container absolute top-1/2 col-span-1 flex -translate-y-1/2 items-center md:static md:top-0 md:col-span-2 md:flex md:translate-y-0 lg:col-span-1">
|
||||
{props.children}
|
||||
</div>
|
||||
</div>
|
||||
<SiteFooter className="border-none" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
130
apps/nextjs/src/app/(auth)/signin/email-signin.tsx
Normal file
130
apps/nextjs/src/app/(auth)/signin/email-signin.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useSignIn, useSignUp } from "@clerk/nextjs";
|
||||
|
||||
import { Button } from "@acme/ui/button";
|
||||
import * as Icons from "@acme/ui/icons";
|
||||
import { Input } from "@acme/ui/input";
|
||||
import { useToast } from "@acme/ui/use-toast";
|
||||
|
||||
export function EmailSignIn() {
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
|
||||
const { signIn, isLoaded: signInLoaded, setActive } = useSignIn();
|
||||
const { signUp, isLoaded: signUpLoaded } = useSignUp();
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
|
||||
const signInWithLink = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const email = new FormData(e.currentTarget).get("email");
|
||||
if (!signInLoaded || typeof email !== "string") return null;
|
||||
|
||||
// the catch here prints out the error.
|
||||
// if the user doesn't exist we will return a 422 in the network response.
|
||||
// so push that to the sign up.
|
||||
setIsLoading(true);
|
||||
await signIn
|
||||
.create({
|
||||
identifier: email,
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log("sign-in error", JSON.stringify(error));
|
||||
});
|
||||
|
||||
const firstFactor = signIn.supportedFirstFactors.find(
|
||||
(f) => f.strategy === "email_link",
|
||||
// This cast shouldn't be necessary but because TypeScript is dumb and can't infer it.
|
||||
) as { emailAddressId: string } | undefined;
|
||||
|
||||
if (firstFactor) {
|
||||
const magicFlow = signIn.createMagicLinkFlow();
|
||||
|
||||
setIsLoading(false);
|
||||
toast({
|
||||
title: "Email Sent",
|
||||
description: "Check your inbox for a verification email.",
|
||||
});
|
||||
const response = await magicFlow
|
||||
.startMagicLinkFlow({
|
||||
emailAddressId: firstFactor.emailAddressId,
|
||||
redirectUrl: `${window.location.origin}/`,
|
||||
})
|
||||
.catch(() => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error",
|
||||
description: "Something went wrong, please try again.",
|
||||
});
|
||||
});
|
||||
|
||||
const verification = response?.firstFactorVerification;
|
||||
if (verification?.status === "expired") {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Link Expired",
|
||||
description: "Link expired, please try again.",
|
||||
});
|
||||
}
|
||||
|
||||
magicFlow.cancelMagicLinkFlow();
|
||||
if (response?.status === "complete") {
|
||||
await setActive({ session: response.createdSessionId }).then(() =>
|
||||
router.push(`/dashboard`),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (!signUpLoaded) return null;
|
||||
await signUp.create({
|
||||
emailAddress: email,
|
||||
});
|
||||
const { startMagicLinkFlow } = signUp.createMagicLinkFlow();
|
||||
|
||||
setIsLoading(false);
|
||||
toast({
|
||||
title: "Email Sent",
|
||||
description: "Check your inbox for a verification email.",
|
||||
});
|
||||
const response = await startMagicLinkFlow({
|
||||
redirectUrl: `${window.location.origin}/`,
|
||||
})
|
||||
.catch(() => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error",
|
||||
description: "Something went wrong, please try again.",
|
||||
});
|
||||
})
|
||||
.then((res) => res);
|
||||
|
||||
if (response?.status === "complete") {
|
||||
await setActive({ session: response.createdSessionId }).then(() =>
|
||||
router.push(`/dashboard`),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form className="grid gap-2" onSubmit={signInWithLink}>
|
||||
<div className="grid gap-1">
|
||||
<Input
|
||||
name="email"
|
||||
placeholder="name@example.com"
|
||||
type="email"
|
||||
autoCapitalize="none"
|
||||
autoComplete="email"
|
||||
autoCorrect="off"
|
||||
className="bg-background"
|
||||
/>
|
||||
</div>
|
||||
<Button disabled={isLoading}>
|
||||
{isLoading && <Icons.Spinner className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Sign In with Email
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
64
apps/nextjs/src/app/(auth)/signin/oauth-signin.tsx
Normal file
64
apps/nextjs/src/app/(auth)/signin/oauth-signin.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { useSignIn } from "@clerk/nextjs";
|
||||
import type { OAuthStrategy } from "@clerk/types";
|
||||
|
||||
import { Button } from "@acme/ui/button";
|
||||
import * as Icons from "@acme/ui/icons";
|
||||
import { useToast } from "@acme/ui/use-toast";
|
||||
|
||||
export function OAuthSignIn() {
|
||||
const [isLoading, setIsLoading] = React.useState<OAuthStrategy | null>(null);
|
||||
const { signIn, isLoaded: signInLoaded } = useSignIn();
|
||||
const { toast } = useToast();
|
||||
|
||||
const oauthSignIn = async (provider: OAuthStrategy) => {
|
||||
if (!signInLoaded) return null;
|
||||
try {
|
||||
setIsLoading(provider);
|
||||
await signIn.authenticateWithRedirect({
|
||||
strategy: provider,
|
||||
redirectUrl: "/sso-callback",
|
||||
redirectUrlComplete: "/dashboard",
|
||||
});
|
||||
} catch (cause) {
|
||||
console.error(cause);
|
||||
setIsLoading(null);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error",
|
||||
description: "Something went wrong, please try again.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="bg-background"
|
||||
onClick={() => oauthSignIn("oauth_github")}
|
||||
>
|
||||
{isLoading === "oauth_github" ? (
|
||||
<Icons.Spinner className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Icons.GitHub className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Github
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="bg-background"
|
||||
onClick={() => oauthSignIn("oauth_google")}
|
||||
>
|
||||
{isLoading === "oauth_google" ? (
|
||||
<Icons.Spinner className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Icons.Google className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Google
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
56
apps/nextjs/src/app/(auth)/signin/page.tsx
Normal file
56
apps/nextjs/src/app/(auth)/signin/page.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import type { Route } from "next";
|
||||
import Link from "next/link";
|
||||
|
||||
import { EmailSignIn } from "./email-signin";
|
||||
import { OAuthSignIn } from "./oauth-signin";
|
||||
|
||||
export const runtime = "edge";
|
||||
|
||||
export default function AuthenticationPage() {
|
||||
return (
|
||||
<div className="mx-auto flex w-full flex-col justify-center space-y-6 sm:w-[350px]">
|
||||
<div className="flex flex-col space-y-2 text-center">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
Create an account
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Enter your email below to create your account
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-6">
|
||||
<EmailSignIn />
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="w-full border-t" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-background px-2 text-muted-foreground">
|
||||
Or continue with
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<OAuthSignIn />
|
||||
</div>
|
||||
|
||||
<p className="px-8 text-center text-sm text-muted-foreground">
|
||||
By clicking continue, you agree to our{" "}
|
||||
<Link
|
||||
href={"/terms" as Route}
|
||||
className="underline underline-offset-4 hover:text-primary"
|
||||
>
|
||||
Terms of Service
|
||||
</Link>{" "}
|
||||
and{" "}
|
||||
<Link
|
||||
href={"/privacy" as Route}
|
||||
className="underline underline-offset-4 hover:text-primary"
|
||||
>
|
||||
Privacy Policy
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
26
apps/nextjs/src/app/(auth)/signout/page.tsx
Normal file
26
apps/nextjs/src/app/(auth)/signout/page.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { SignOutButton } from "@clerk/nextjs";
|
||||
|
||||
import { Button } from "@acme/ui/button";
|
||||
|
||||
export const runtime = "edge";
|
||||
|
||||
export default function AuthenticationPage() {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full flex-col justify-center space-y-6 sm:w-[350px]">
|
||||
<div className="flex flex-col space-y-2 text-center">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Sign Out</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Are you sure you want to sign out?
|
||||
</p>
|
||||
<SignOutButton signOutCallback={() => router.push("/?redirect=false")}>
|
||||
<Button>Confirm</Button>
|
||||
</SignOutButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
25
apps/nextjs/src/app/(auth)/sso-callback/page.tsx
Normal file
25
apps/nextjs/src/app/(auth)/sso-callback/page.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useClerk } from "@clerk/nextjs";
|
||||
import type { HandleOAuthCallbackParams } from "@clerk/types";
|
||||
|
||||
import * as Icons from "@acme/ui/icons";
|
||||
|
||||
export const runtime = "edge";
|
||||
|
||||
export default function SSOCallback(props: {
|
||||
searchParams: HandleOAuthCallbackParams;
|
||||
}) {
|
||||
const { handleRedirectCallback } = useClerk();
|
||||
|
||||
useEffect(() => {
|
||||
void handleRedirectCallback(props.searchParams);
|
||||
}, [props.searchParams, handleRedirectCallback]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center">
|
||||
<Icons.Spinner className="mr-2 h-16 w-16 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@acme/ui/card";
|
||||
import * as Icons from "@acme/ui/icons";
|
||||
|
||||
export function LoadingCard(props: {
|
||||
title: string;
|
||||
description: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<Card className={props.className}>
|
||||
<CardHeader>
|
||||
<CardTitle>{props.title}</CardTitle>
|
||||
<CardDescription>{props.description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex items-center justify-center">
|
||||
<Icons.Spinner className="m-6 h-16 w-16 animate-spin" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
import { Bar, BarChart, ResponsiveContainer, XAxis, YAxis } from "recharts";
|
||||
|
||||
const data = [
|
||||
{
|
||||
name: "Jan",
|
||||
total: Math.floor(Math.random() * 5000) + 1000,
|
||||
},
|
||||
{
|
||||
name: "Feb",
|
||||
total: Math.floor(Math.random() * 5000) + 1000,
|
||||
},
|
||||
{
|
||||
name: "Mar",
|
||||
total: Math.floor(Math.random() * 5000) + 1000,
|
||||
},
|
||||
{
|
||||
name: "Apr",
|
||||
total: Math.floor(Math.random() * 5000) + 1000,
|
||||
},
|
||||
{
|
||||
name: "May",
|
||||
total: Math.floor(Math.random() * 5000) + 1000,
|
||||
},
|
||||
{
|
||||
name: "Jun",
|
||||
total: Math.floor(Math.random() * 5000) + 1000,
|
||||
},
|
||||
{
|
||||
name: "Jul",
|
||||
total: Math.floor(Math.random() * 5000) + 1000,
|
||||
},
|
||||
{
|
||||
name: "Aug",
|
||||
total: Math.floor(Math.random() * 5000) + 1000,
|
||||
},
|
||||
{
|
||||
name: "Sep",
|
||||
total: Math.floor(Math.random() * 5000) + 1000,
|
||||
},
|
||||
{
|
||||
name: "Oct",
|
||||
total: Math.floor(Math.random() * 5000) + 1000,
|
||||
},
|
||||
{
|
||||
name: "Nov",
|
||||
total: Math.floor(Math.random() * 5000) + 1000,
|
||||
},
|
||||
{
|
||||
name: "Dec",
|
||||
total: Math.floor(Math.random() * 5000) + 1000,
|
||||
},
|
||||
];
|
||||
|
||||
export function Overview() {
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={400}>
|
||||
<BarChart data={data}>
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
stroke="#888888"
|
||||
fontSize={12}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="#888888"
|
||||
fontSize={12}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(value) => `$${value}`}
|
||||
/>
|
||||
<Bar dataKey="total" fill="#adfa1d" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
createColumnHelper,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
import { format, formatRelative } from "date-fns";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
|
||||
import type { RouterOutputs } from "@acme/api";
|
||||
import { cn } from "@acme/ui";
|
||||
import { Button } from "@acme/ui/button";
|
||||
import { Checkbox } from "@acme/ui/checkbox";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@acme/ui/dropdown-menu";
|
||||
import * as Icons from "@acme/ui/icons";
|
||||
import { Label } from "@acme/ui/label";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@acme/ui/table";
|
||||
import { useToast } from "@acme/ui/use-toast";
|
||||
|
||||
import { api } from "~/trpc/client";
|
||||
|
||||
export type ApiKeyColumn = RouterOutputs["project"]["listApiKeys"][number];
|
||||
|
||||
const columnHelper = createColumnHelper<ApiKeyColumn>();
|
||||
|
||||
const columns = [
|
||||
columnHelper.display({
|
||||
id: "select",
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
checked={table.getIsAllRowsSelected()}
|
||||
disabled={
|
||||
table.getRowModel().rows.length === 0 ||
|
||||
table
|
||||
.getRowModel()
|
||||
.rows.every((row) => row.original.revokedAt !== null)
|
||||
}
|
||||
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||
aria-label="Select all"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
disabled={row.original.revokedAt !== null}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
aria-label="Select"
|
||||
/>
|
||||
),
|
||||
}),
|
||||
columnHelper.accessor("key", {
|
||||
cell: function Key(t) {
|
||||
const [show, setShow] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const key = t.getValue();
|
||||
|
||||
const displayText = show ? key : "sk_live_****************";
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<span
|
||||
className={cn(
|
||||
"font-mono",
|
||||
t.row.original.revokedAt !== null && "line-through",
|
||||
)}
|
||||
>
|
||||
{displayText}
|
||||
</span>
|
||||
<div className="invisible flex items-center gap-2 group-hover:visible">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-4 w-4 p-0 opacity-50"
|
||||
disabled={show}
|
||||
onClick={() => {
|
||||
setShow(true);
|
||||
setTimeout(() => {
|
||||
setShow(false);
|
||||
}, 1500);
|
||||
}}
|
||||
>
|
||||
<span className="sr-only">Toggle key visibility</span>
|
||||
{show ? <EyeOff /> : <Eye />}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-4 w-4 p-0 opacity-50"
|
||||
onClick={async () => {
|
||||
setCopied(true);
|
||||
await Promise.all([
|
||||
navigator.clipboard.writeText(key),
|
||||
new Promise((resolve) => setTimeout(resolve, 1500)),
|
||||
]);
|
||||
setCopied(false);
|
||||
}}
|
||||
>
|
||||
<span className="sr-only">Copy key</span>
|
||||
{copied ? <Icons.CopyDone /> : <Icons.Copy />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
header: "Key",
|
||||
}),
|
||||
columnHelper.accessor("createdAt", {
|
||||
cell: (t) => format(t.getValue(), "yyyy-MM-dd"),
|
||||
header: "Created At",
|
||||
}),
|
||||
columnHelper.accessor("expiresAt", {
|
||||
cell: (t) => {
|
||||
if (t.row.original.revokedAt !== null) {
|
||||
return (
|
||||
<div className="flex flex-col text-destructive">
|
||||
<span>Revoked</span>
|
||||
<span>{format(t.row.original.revokedAt, "yyyy-MM-dd")}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const value = t.getValue();
|
||||
if (value === null) {
|
||||
return "Never expires";
|
||||
}
|
||||
|
||||
if (value < new Date()) {
|
||||
return (
|
||||
<div className="flex flex-col text-destructive">
|
||||
<span>Expired</span>
|
||||
<span>{format(value, "yyyy-MM-dd")}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return format(value, "yyyy-MM-dd");
|
||||
},
|
||||
header: "Expires At",
|
||||
}),
|
||||
columnHelper.accessor("lastUsed", {
|
||||
cell: (t) => {
|
||||
const value = t.getValue();
|
||||
if (value === null) {
|
||||
return "Never used";
|
||||
}
|
||||
return formatRelative(value, new Date());
|
||||
},
|
||||
header: "Last Used At",
|
||||
}),
|
||||
columnHelper.display({
|
||||
id: "actions",
|
||||
header: function ActionsHeader(t) {
|
||||
const router = useRouter();
|
||||
const toaster = useToast();
|
||||
|
||||
const { rows } = t.table.getSelectedRowModel();
|
||||
const ids = rows.map((row) => row.original.id);
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild disabled={ids.length < 1}>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">Open menu</span>
|
||||
<Icons.Ellipsis className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={async () => {
|
||||
try {
|
||||
const res = await api.project.revokeApiKeys.mutate({ ids });
|
||||
router.refresh();
|
||||
toaster.toast({
|
||||
title: `Revoked ${res.numRevoked} API keys`,
|
||||
});
|
||||
t.table.toggleAllRowsSelected(false);
|
||||
} catch {
|
||||
toaster.toast({
|
||||
title: "Failed to revoke API Keys",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="text-destructive"
|
||||
>
|
||||
Revoke {ids.length} API key{ids.length > 1 ? "s" : ""}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
},
|
||||
cell: function Actions(t) {
|
||||
const apiKey = t.row.original;
|
||||
const router = useRouter();
|
||||
const toaster = useToast();
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">Open menu</span>
|
||||
<Icons.Ellipsis className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={async () => {
|
||||
try {
|
||||
await api.project.revokeApiKeys.mutate({ ids: [apiKey.id] });
|
||||
t.row.toggleSelected(false);
|
||||
router.refresh();
|
||||
toaster.toast({ title: "API Key revoked" });
|
||||
} catch {
|
||||
toaster.toast({
|
||||
title: "Failed to revoke API Key",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="text-destructive"
|
||||
>
|
||||
Revoke Key
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
onClick={async () => {
|
||||
try {
|
||||
await api.project.rollApiKey.mutate({ id: apiKey.id });
|
||||
router.refresh();
|
||||
toaster.toast({ title: "API Key rolled" });
|
||||
} catch {
|
||||
toaster.toast({
|
||||
title: "Failed to roll API Key",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="text-destructive"
|
||||
>
|
||||
Roll Key
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
export function DataTable(props: { data: ApiKeyColumn[] }) {
|
||||
const [rowSelection, setRowSelection] = useState({});
|
||||
const [showRevoked, setShowRevoked] = useState(true);
|
||||
|
||||
const table = useReactTable({
|
||||
data: props.data,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
onRowSelectionChange: setRowSelection,
|
||||
enableRowSelection: (row) => {
|
||||
return row.original.revokedAt === null;
|
||||
},
|
||||
state: {
|
||||
rowSelection,
|
||||
},
|
||||
});
|
||||
|
||||
const filteredRows = showRevoked
|
||||
? table.getRowModel().rows
|
||||
: table
|
||||
.getRowModel()
|
||||
?.rows.filter((row) => row.original.revokedAt === null);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 py-2">
|
||||
<Label>Show revoked</Label>
|
||||
<Checkbox
|
||||
checked={showRevoked}
|
||||
onCheckedChange={(c) => setShowRevoked(!!c)}
|
||||
className="max-w-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredRows.length ? (
|
||||
filteredRows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
disabled={(() => {
|
||||
if (row.original.revokedAt !== null) {
|
||||
return true;
|
||||
}
|
||||
if (row.original.expiresAt !== null) {
|
||||
return row.original.expiresAt < new Date();
|
||||
}
|
||||
return false;
|
||||
})()}
|
||||
className={cn("group")}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
No results.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { DashboardShell } from "~/app/(dashboard)/_components/dashboard-shell";
|
||||
import { DataTable } from "./data-table";
|
||||
import { NewApiKeyDialog } from "./new-api-key-dialog";
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<DashboardShell
|
||||
title="API Keys"
|
||||
description="Manage your API Keys"
|
||||
headerAction={<NewApiKeyDialog projectId="" />}
|
||||
>
|
||||
<DataTable data={[]} />
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { Button } from "@acme/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@acme/ui/dialog";
|
||||
|
||||
import { CreateApiKeyForm } from "../../_components/create-api-key-form";
|
||||
|
||||
export function NewApiKeyDialog(props: { projectId: string }) {
|
||||
const router = useRouter();
|
||||
|
||||
const [dialogOpen, setDialogOpen] = React.useState(false);
|
||||
|
||||
return (
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>Create API Key</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create API Key</DialogTitle>
|
||||
<DialogDescription>
|
||||
Fill out the form to create an API key.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<CreateApiKeyForm
|
||||
projectId={props.projectId}
|
||||
onSuccess={() => {
|
||||
setDialogOpen(false);
|
||||
router.refresh();
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { DashboardShell } from "~/app/(dashboard)/_components/dashboard-shell";
|
||||
import { userCanAccess } from "~/lib/project-guard";
|
||||
import { api } from "~/trpc/server";
|
||||
import { DataTable } from "./data-table";
|
||||
import { NewApiKeyDialog } from "./new-api-key-dialog";
|
||||
|
||||
export const runtime = "edge";
|
||||
|
||||
export default async function ApiKeysPage(props: {
|
||||
params: { projectId: string; workspaceId: string };
|
||||
}) {
|
||||
await userCanAccess(props.params.projectId);
|
||||
|
||||
const apiKeys = await api.project.listApiKeys.query({
|
||||
projectId: props.params.projectId,
|
||||
});
|
||||
|
||||
return (
|
||||
<DashboardShell
|
||||
title="API Keys"
|
||||
description="Manage your API Keys"
|
||||
headerAction={<NewApiKeyDialog projectId={props.params.projectId} />}
|
||||
>
|
||||
<DataTable data={apiKeys} />
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
"use client";
|
||||
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
|
||||
import { Button } from "@acme/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@acme/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@acme/ui/dialog";
|
||||
import * as Icons from "@acme/ui/icons";
|
||||
import { useToast } from "@acme/ui/use-toast";
|
||||
|
||||
import { api } from "~/trpc/client";
|
||||
|
||||
export function DeleteProject() {
|
||||
const { projectId } = useParams<{ projectId: string }>();
|
||||
const toaster = useToast();
|
||||
const router = useRouter();
|
||||
|
||||
const title = "Delete project";
|
||||
const description = "This will delete the project and all of its data.";
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{title}</CardTitle>
|
||||
<CardDescription className="flex items-center">
|
||||
{description}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardFooter className="flex justify-between">
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="destructive">{title}</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex items-center font-bold text-destructive">
|
||||
<Icons.Warning className="mr-2 h-6 w-6" />
|
||||
<p>This action can not be reverted</p>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="secondary">Cancel</Button>
|
||||
</DialogClose>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={async () => {
|
||||
try {
|
||||
if (!projectId) throw new Error("No project ID");
|
||||
|
||||
await api.project.delete.mutate({
|
||||
id: projectId,
|
||||
});
|
||||
toaster.toast({ title: "Project deleted" });
|
||||
router.push(`/dashboard`);
|
||||
} catch {
|
||||
toaster.toast({
|
||||
title: "Project could not be deleted",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
{`I'm sure. Delete this project`}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Button } from "@acme/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@acme/ui/card";
|
||||
|
||||
import { DashboardShell } from "~/app/(dashboard)/_components/dashboard-shell";
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<DashboardShell
|
||||
title="Danger Zone"
|
||||
description="Do dangerous stuff here"
|
||||
className="space-y-6"
|
||||
>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Transfer to Organization</CardTitle>
|
||||
<CardDescription className="flex items-center">
|
||||
Transfer this project to an organization
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardFooter className="flex justify-between">
|
||||
<Button variant="destructive" disabled>
|
||||
Transfer to Organization
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Transfer to Personal</CardTitle>
|
||||
<CardDescription className="flex items-center">
|
||||
Transfer this project to your personal workspace
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardFooter className="flex justify-between">
|
||||
<Button variant="destructive" disabled>
|
||||
Transfer to Personal
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Delete project</CardTitle>
|
||||
<CardDescription className="flex items-center">
|
||||
This will delete the project and all of its data.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardFooter className="flex justify-between">
|
||||
<Button variant="destructive" disabled>
|
||||
Delete project
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { Button } from "@acme/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@acme/ui/card";
|
||||
|
||||
import { DashboardShell } from "~/app/(dashboard)/_components/dashboard-shell";
|
||||
import { userCanAccess } from "~/lib/project-guard";
|
||||
import { api } from "~/trpc/server";
|
||||
import { DeleteProject } from "./delete-project";
|
||||
import { TransferProjectToOrganization } from "./transfer-to-organization";
|
||||
import { TransferProjectToPersonal } from "./transfer-to-personal";
|
||||
|
||||
export const runtime = "edge";
|
||||
|
||||
export default async function DangerZonePage(props: {
|
||||
params: { projectId: string; workspaceId: string };
|
||||
}) {
|
||||
await userCanAccess(props.params.projectId);
|
||||
|
||||
return (
|
||||
<DashboardShell
|
||||
title="Danger Zone"
|
||||
description="Do dangerous stuff here"
|
||||
className="space-y-4"
|
||||
>
|
||||
<Suspense
|
||||
fallback={
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Transfer to Organization</CardTitle>
|
||||
<CardDescription className="flex items-center">
|
||||
Transfer this project to an organization
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardFooter className="flex justify-between">
|
||||
<Button variant="destructive">Transfer to Organization</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
}
|
||||
>
|
||||
<TransferProjectToOrganization
|
||||
orgsPromise={api.auth.listOrganizations.query()}
|
||||
/>
|
||||
</Suspense>
|
||||
<TransferProjectToPersonal />
|
||||
<DeleteProject />
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
"use client";
|
||||
|
||||
import { use } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
|
||||
import type { TransferToOrg } from "@acme/api/validators";
|
||||
import { transferToOrgSchema } from "@acme/api/validators";
|
||||
import { Button } from "@acme/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@acme/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@acme/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@acme/ui/form";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@acme/ui/select";
|
||||
import { useToast } from "@acme/ui/use-toast";
|
||||
|
||||
import { useZodForm } from "~/lib/zod-form";
|
||||
import type { RouterOutputs } from "~/trpc/client";
|
||||
import { api } from "~/trpc/client";
|
||||
|
||||
export function TransferProjectToOrganization(props: {
|
||||
orgsPromise: Promise<RouterOutputs["auth"]["listOrganizations"]>;
|
||||
}) {
|
||||
const { workspaceId, projectId } = useParams<{
|
||||
workspaceId: string;
|
||||
projectId: string;
|
||||
}>();
|
||||
const orgs = use(props.orgsPromise);
|
||||
|
||||
const toaster = useToast();
|
||||
const router = useRouter();
|
||||
|
||||
const form = useZodForm({
|
||||
schema: transferToOrgSchema,
|
||||
defaultValues: {
|
||||
projectId,
|
||||
},
|
||||
});
|
||||
|
||||
async function onSubmit(data: TransferToOrg) {
|
||||
try {
|
||||
if (!projectId) throw new Error("No project ID");
|
||||
|
||||
await api.project.transferToOrganization.mutate(data);
|
||||
toaster.toast({ title: "Project transferred" });
|
||||
router.push(`/${data.orgId}/${projectId}`);
|
||||
} catch {
|
||||
toaster.toast({
|
||||
title: "Project could not be transferred",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const title = "Transfer to Organization";
|
||||
const description = "Transfer this project to an organization";
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{title}</CardTitle>
|
||||
<CardDescription className="flex items-center">
|
||||
{description}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardFooter className="flex justify-between">
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="destructive">{title}</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="orgId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Organization</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a plan" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{orgs
|
||||
.filter((org) => org.id !== workspaceId)
|
||||
.map((org) => (
|
||||
<SelectItem key={org.id} value={org.id}>
|
||||
{org.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="secondary">Cancel</Button>
|
||||
</DialogClose>
|
||||
<Button variant="destructive" type="submit">
|
||||
{`I'm sure. Transfer this project`}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useAuth } from "@clerk/nextjs";
|
||||
|
||||
import { Button } from "@acme/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@acme/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@acme/ui/dialog";
|
||||
import { useToast } from "@acme/ui/use-toast";
|
||||
|
||||
import { api } from "~/trpc/client";
|
||||
|
||||
export function TransferProjectToPersonal() {
|
||||
const { projectId } = useParams<{ projectId: string }>();
|
||||
const { userId } = useAuth();
|
||||
const toaster = useToast();
|
||||
const router = useRouter();
|
||||
|
||||
const title = "Transfer to Personal";
|
||||
const description = "Transfer this project to your personal workspace";
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{title}</CardTitle>
|
||||
<CardDescription className="flex items-center">
|
||||
{description}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardFooter className="flex justify-between">
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="destructive">{title}</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="secondary">Cancel</Button>
|
||||
</DialogClose>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={async () => {
|
||||
try {
|
||||
if (!projectId) throw new Error("No project ID");
|
||||
|
||||
await api.project.transferToPersonal.mutate({
|
||||
id: projectId,
|
||||
});
|
||||
toaster.toast({ title: "Project transferred" });
|
||||
router.push(`/${userId}/${projectId}`);
|
||||
} catch {
|
||||
toaster.toast({
|
||||
title: "Project could not be transferred",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
{`I'm sure. Transfer this project`}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
import { Button } from "@acme/ui/button";
|
||||
|
||||
import { DashboardShell } from "../../_components/dashboard-shell";
|
||||
|
||||
export default function Error(props: { error: Error; reset: () => void }) {
|
||||
React.useEffect(() => {
|
||||
// Log the error to an error reporting service
|
||||
console.error(props.error);
|
||||
}, [props.error]);
|
||||
|
||||
// This should prob go in some config to make sure it's synced between loading.tsx, page.tsx and error.tsx etc
|
||||
const pathname = usePathname();
|
||||
const path = pathname.split("/")[3];
|
||||
const { title, description } = (() => {
|
||||
switch (path) {
|
||||
case "ingestions":
|
||||
return {
|
||||
title: "Ingestions",
|
||||
description: "Ingestion details",
|
||||
};
|
||||
case "pulls":
|
||||
return {
|
||||
title: "Pull Request",
|
||||
description: "Browse pull requests changes",
|
||||
};
|
||||
default:
|
||||
return {
|
||||
title: "Overview",
|
||||
description: "Get an overview of how the project is going",
|
||||
};
|
||||
}
|
||||
})();
|
||||
|
||||
return (
|
||||
<DashboardShell title={title} description={description} breadcrumb>
|
||||
<div className="flex h-[600px] flex-col items-center justify-center gap-4 rounded-lg border border-dashed">
|
||||
<h2 className="text-xl font-bold">Something went wrong!</h2>
|
||||
<p className="text-base text-muted-foreground">
|
||||
{`We're sorry, something went wrong. Please try again.`}
|
||||
</p>
|
||||
<Button onClick={() => props.reset()}>Try again</Button>
|
||||
</div>
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { format } from "date-fns";
|
||||
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@acme/ui/table";
|
||||
|
||||
import { DashboardShell } from "~/app/(dashboard)/_components/dashboard-shell";
|
||||
import { userCanAccess } from "~/lib/project-guard";
|
||||
import { api } from "~/trpc/server";
|
||||
|
||||
export const runtime = "edge";
|
||||
|
||||
export default async function IngestionPage(props: {
|
||||
params: { workspaceId: string; projectId: string; ingestionId: string };
|
||||
}) {
|
||||
await userCanAccess(props.params.projectId);
|
||||
|
||||
const ingestion = await api.ingestion.byId.query({
|
||||
id: props.params.ingestionId,
|
||||
});
|
||||
|
||||
return (
|
||||
<DashboardShell
|
||||
title="Ingestion"
|
||||
description="Ingestion details"
|
||||
className="space-y-4"
|
||||
>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="pointer-events-none bg-muted">
|
||||
<TableHead>Id</TableHead>
|
||||
<TableHead>Created At</TableHead>
|
||||
<TableHead>Commit</TableHead>
|
||||
<TableHead>Origin</TableHead>
|
||||
<TableHead>Parent</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell>{ingestion.id}</TableCell>
|
||||
<TableCell>
|
||||
{format(ingestion.createdAt, "yyyy-MM-dd HH:mm:ss")}
|
||||
</TableCell>
|
||||
<TableCell>{ingestion.hash}</TableCell>
|
||||
<TableCell>{ingestion.origin}</TableCell>
|
||||
<TableCell>{ingestion.parent}</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
<h3 className="text-lg font-medium">Schema</h3>
|
||||
<pre className="relative rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono text-sm font-semibold">
|
||||
{JSON.stringify(ingestion.schema, null, 4)}
|
||||
</pre>
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { DashboardShell } from "../../../_components/dashboard-shell";
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<DashboardShell
|
||||
title="Overview"
|
||||
description="Get an overview of how the project is going"
|
||||
breadcrumb
|
||||
>
|
||||
<div className="h-[600px] animate-pulse rounded-lg bg-muted"></div>
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import { Suspense } from "react";
|
||||
import Link from "next/link";
|
||||
import { formatRelative } from "date-fns";
|
||||
import { Activity, CreditCard, DollarSign, Users } from "lucide-react";
|
||||
|
||||
import { cn } from "@acme/ui";
|
||||
import { Button } from "@acme/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@acme/ui/card";
|
||||
import * as Icons from "@acme/ui/icons";
|
||||
|
||||
import { Overview } from "~/app/(dashboard)/[workspaceId]/[projectId]/_components/overview";
|
||||
import { userCanAccess } from "~/lib/project-guard";
|
||||
import type { RouterOutputs } from "~/trpc/server";
|
||||
import { api } from "~/trpc/server";
|
||||
import { LoadingCard } from "../_components/loading-card";
|
||||
import { DashboardShell } from "../../../_components/dashboard-shell";
|
||||
|
||||
export const runtime = "edge";
|
||||
|
||||
export default async function DashboardPage(props: {
|
||||
params: { workspaceId: string; projectId: string };
|
||||
}) {
|
||||
const { projectId, workspaceId } = props.params;
|
||||
await userCanAccess(projectId);
|
||||
|
||||
return (
|
||||
<DashboardShell
|
||||
title="Overview"
|
||||
description="Get an overview of how the project is going"
|
||||
breadcrumb
|
||||
className="space-y-4"
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Revenue</CardTitle>
|
||||
<DollarSign className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">$45,231.89</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
+20.1% from last month
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Subscriptions</CardTitle>
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">+2350</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
+180.1% from last month
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Sales</CardTitle>
|
||||
<CreditCard className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">+12,234</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
+19% from last month
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Active Now</CardTitle>
|
||||
<Activity className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">+573</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
+201 since last hour
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-7">
|
||||
<Card className="col-span-7 md:col-span-2 lg:col-span-4">
|
||||
<CardHeader>
|
||||
<CardTitle>Overview</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pl-2">
|
||||
<Overview />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Suspense
|
||||
fallback={
|
||||
<LoadingCard
|
||||
title="Recent Ingestions"
|
||||
description="Loading recent ingestions..."
|
||||
className="col-span-7 md:col-span-2 lg:col-span-3"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<RecentIngestions projectId={projectId} workspaceId={workspaceId} />
|
||||
</Suspense>
|
||||
</div>
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
|
||||
function IngestionCard(props: {
|
||||
projectId: string;
|
||||
workspaceId: string;
|
||||
ingestion: RouterOutputs["ingestion"]["list"][number];
|
||||
}) {
|
||||
const { ingestion } = props;
|
||||
const { adds, subs } = ingestion;
|
||||
|
||||
const N_SQUARES = 5;
|
||||
const addSquares = Math.round((adds / (adds + subs)) * N_SQUARES);
|
||||
|
||||
const truncatedHash = ingestion.hash.slice(0, 15);
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/${props.workspaceId}/${props.projectId}/ingestions/${ingestion.id}`}
|
||||
>
|
||||
<div className="flex items-center rounded p-1 hover:bg-muted">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium leading-none">{truncatedHash}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatRelative(ingestion.createdAt, new Date())}
|
||||
</p>
|
||||
</div>
|
||||
<div className="ml-auto flex flex-col items-center text-sm">
|
||||
<div>
|
||||
+{adds} -{subs}
|
||||
</div>
|
||||
<div className="flex gap-[2px]">
|
||||
{new Array(N_SQUARES).fill(null).map((_, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={cn(
|
||||
"inline-block h-2 w-2",
|
||||
i < addSquares ? "bg-green-500" : "bg-red-500",
|
||||
adds + subs === 0 && "bg-gray-200",
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Icons.ChevronRight className="ml-2 h-4 w-4" />
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
async function RecentIngestions(props: {
|
||||
projectId: string;
|
||||
workspaceId: string;
|
||||
}) {
|
||||
const ingestions = await api.ingestion.list.query({
|
||||
projectId: props.projectId,
|
||||
limit: 5,
|
||||
});
|
||||
|
||||
return (
|
||||
<Card className="col-span-7 md:col-span-2 lg:col-span-3">
|
||||
<CardHeader>
|
||||
<CardTitle>Recent Ingestions</CardTitle>
|
||||
<CardDescription>
|
||||
{ingestions.length} ingestion{ingestions.length > 1 ? "s" : null}{" "}
|
||||
recorded this period.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{ingestions.map((ingestion) => (
|
||||
<IngestionCard
|
||||
key={ingestion.id}
|
||||
ingestion={ingestion}
|
||||
projectId={props.projectId}
|
||||
workspaceId={props.workspaceId}
|
||||
/>
|
||||
))}
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button size="sm" className="ml-auto">
|
||||
View all
|
||||
<Icons.ChevronRight className="ml-1 h-4 w-4" />
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export const runtime = "edge";
|
||||
|
||||
/**
|
||||
* Suboptimal, would be better off doing this in middleware
|
||||
*/
|
||||
export default function ProjectPage(props: {
|
||||
params: { workspaceId: string; projectId: string };
|
||||
}) {
|
||||
redirect(`/${props.params.workspaceId}/${props.params.projectId}/overview`);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import type { RenameProject } from "@acme/api/validators";
|
||||
import { renameProjectSchema } from "@acme/api/validators";
|
||||
import { Button } from "@acme/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@acme/ui/card";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@acme/ui/form";
|
||||
import { Input } from "@acme/ui/input";
|
||||
import { useToast } from "@acme/ui/use-toast";
|
||||
|
||||
import { useZodForm } from "~/lib/zod-form";
|
||||
import { api } from "~/trpc/client";
|
||||
|
||||
export function RenameProject(props: {
|
||||
currentName: string;
|
||||
projectId: string;
|
||||
}) {
|
||||
const { toast } = useToast();
|
||||
|
||||
const form = useZodForm({
|
||||
schema: renameProjectSchema,
|
||||
defaultValues: {
|
||||
projectId: props.projectId,
|
||||
name: props.currentName,
|
||||
},
|
||||
});
|
||||
|
||||
async function onSubmit(data: RenameProject) {
|
||||
await api.project.rename.mutate(data);
|
||||
toast({
|
||||
title: "Project name updated",
|
||||
description: "Your project's name has been updated.",
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Project name</CardTitle>
|
||||
<CardDescription>
|
||||
Change the display name of your project
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-2">
|
||||
<CardContent>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="my-project" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button type="submit" className="ml-auto">
|
||||
Save
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { DashboardShell } from "~/app/(dashboard)/_components/dashboard-shell";
|
||||
import { RenameProject } from "./_components/rename-project";
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<DashboardShell
|
||||
title="Project"
|
||||
description="Manage your project"
|
||||
className="space-y-4"
|
||||
>
|
||||
<RenameProject currentName="" projectId="" />
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { DashboardShell } from "~/app/(dashboard)/_components/dashboard-shell";
|
||||
import { api } from "~/trpc/server";
|
||||
import { RenameProject } from "./_components/rename-project";
|
||||
|
||||
export const runtime = "edge";
|
||||
|
||||
export default async function ProjectSettingsPage(props: {
|
||||
params: { workspaceId: string; projectId: string };
|
||||
}) {
|
||||
const { projectId } = props.params;
|
||||
const project = await api.project.byId.query({ id: projectId });
|
||||
|
||||
return (
|
||||
<DashboardShell
|
||||
title="Project"
|
||||
description="Manage your project"
|
||||
className="space-y-4"
|
||||
>
|
||||
<RenameProject currentName={project.name} projectId={projectId} />
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { add, format } from "date-fns";
|
||||
import { Calendar as CalendarIcon } from "lucide-react";
|
||||
|
||||
import type { CreateApiKey } from "@acme/api/validators";
|
||||
import { createApiKeySchema } from "@acme/api/validators";
|
||||
import { Button } from "@acme/ui/button";
|
||||
import { Calendar } from "@acme/ui/calendar";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@acme/ui/form";
|
||||
import { Input } from "@acme/ui/input";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@acme/ui/popover";
|
||||
import { useToast } from "@acme/ui/use-toast";
|
||||
|
||||
import { useZodForm } from "~/lib/zod-form";
|
||||
import { api } from "~/trpc/client";
|
||||
|
||||
export function CreateApiKeyForm(props: {
|
||||
projectId: string;
|
||||
onSuccess?: (key: string) => void;
|
||||
}) {
|
||||
const toaster = useToast();
|
||||
|
||||
const [datePickerOpen, setDatePickerOpen] = React.useState(false);
|
||||
|
||||
const form = useZodForm({
|
||||
schema: createApiKeySchema,
|
||||
defaultValues: { projectId: props.projectId },
|
||||
});
|
||||
|
||||
async function onSubmit(data: CreateApiKey) {
|
||||
try {
|
||||
const apiKey = await api.project.createApiKey.mutate(data);
|
||||
form.reset();
|
||||
props.onSuccess?.(apiKey);
|
||||
toaster.toast({
|
||||
title: "API Key Created",
|
||||
description: `Project ${data.name} created successfully.`,
|
||||
});
|
||||
} catch (error) {
|
||||
toaster.toast({
|
||||
title: "Error creating API Key",
|
||||
variant: "destructive",
|
||||
description:
|
||||
"An issue occurred while creating your key. Please try again.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name *</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="New Token" />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Enter a unique name for your token to differentiate it from
|
||||
other tokens.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="expiresAt"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col">
|
||||
<FormLabel>Exiration date</FormLabel>
|
||||
<Popover open={datePickerOpen} onOpenChange={setDatePickerOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<FormControl>
|
||||
<Button
|
||||
variant={"outline"}
|
||||
className="pl-3 text-left font-normal"
|
||||
>
|
||||
{field.value ? (
|
||||
format(field.value, "PPP")
|
||||
) : (
|
||||
<span className="text-muted-foreground">
|
||||
Pick a date
|
||||
</span>
|
||||
)}
|
||||
<CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={field.value}
|
||||
onSelect={(date) => {
|
||||
field.onChange(date);
|
||||
setDatePickerOpen(false);
|
||||
}}
|
||||
disabled={(date) =>
|
||||
// future dates up to 1 year only
|
||||
date < new Date() || date > add(new Date(), { years: 1 })
|
||||
}
|
||||
initialFocus
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<FormDescription>
|
||||
We <b>strongly recommend</b> you setting an expiration date for
|
||||
your API key, but you can also leave it blank to create a
|
||||
permanent key.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button type="submit">
|
||||
{form.formState.isSubmitting && (
|
||||
<div className="mr-1" role="status">
|
||||
<div className="h-3 w-3 animate-spin rounded-full border-2 border-background border-r-transparent" />
|
||||
</div>
|
||||
)}
|
||||
Create Key
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import type { CreateProject } from "@acme/api/validators";
|
||||
import { createProjectSchema } from "@acme/api/validators";
|
||||
import { Button } from "@acme/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@acme/ui/form";
|
||||
import { Input } from "@acme/ui/input";
|
||||
import { useToast } from "@acme/ui/use-toast";
|
||||
|
||||
import { useZodForm } from "~/lib/zod-form";
|
||||
import { api } from "~/trpc/client";
|
||||
|
||||
export const CreateProjectForm = (props: {
|
||||
workspaceId: string;
|
||||
// defaults to redirecting to the project page
|
||||
onSuccess?: (project: CreateProject & { id: string }) => void;
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const toaster = useToast();
|
||||
|
||||
const form = useZodForm({ schema: createProjectSchema });
|
||||
|
||||
async function onSubmit(data: CreateProject) {
|
||||
try {
|
||||
const projectId = await api.project.create.mutate(data);
|
||||
if (props.onSuccess) {
|
||||
props.onSuccess({
|
||||
...data,
|
||||
id: projectId,
|
||||
});
|
||||
} else {
|
||||
router.push(`/${props.workspaceId}/${projectId}/overview`);
|
||||
}
|
||||
toaster.toast({
|
||||
title: "Project created",
|
||||
description: `Project ${data.name} created successfully.`,
|
||||
});
|
||||
} catch (error) {
|
||||
toaster.toast({
|
||||
title: "Error creating project",
|
||||
variant: "destructive",
|
||||
description:
|
||||
"An issue occurred while creating your project. Please try again.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name *</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="Acme Corp" />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
A name to identify your app in the dashboard.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="url"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="https://acme-corp.com" />
|
||||
</FormControl>
|
||||
<FormDescription>The URL of your app</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button type="submit">Create Project</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import type { RouterOutputs } from "@acme/api";
|
||||
import { ProjectTier } from "@acme/db";
|
||||
import { cn } from "@acme/ui";
|
||||
import { Card, CardDescription, CardHeader, CardTitle } from "@acme/ui/card";
|
||||
|
||||
import { getRandomPatternStyle } from "~/lib/generate-pattern";
|
||||
|
||||
function ProjectTierIndicator(props: { tier: ProjectTier }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"ml-2 rounded-md px-2 py-1 text-xs no-underline group-hover:no-underline",
|
||||
props.tier === ProjectTier.FREE && "bg-teal-100 dark:bg-teal-600",
|
||||
props.tier === ProjectTier.PRO && "bg-red-100 dark:bg-red-800",
|
||||
)}
|
||||
>
|
||||
{props.tier}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProjectCard(props: {
|
||||
workspaceId: string;
|
||||
project: RouterOutputs["project"]["listByActiveWorkspace"]["projects"][number];
|
||||
}) {
|
||||
const { project } = props;
|
||||
return (
|
||||
<Link href={`/${props.workspaceId}/${project.id}/overview`}>
|
||||
<Card className="overflow-hidden">
|
||||
<div className="h-32" style={getRandomPatternStyle(project.id)} />
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<span>{project.name}</span>
|
||||
<ProjectTierIndicator tier={project.tier} />
|
||||
</CardTitle>
|
||||
<CardDescription>{project.url} </CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
ProjectCard.Skeleton = function ProjectCardSkeleton(props: {
|
||||
pulse?: boolean;
|
||||
}) {
|
||||
const { pulse = true } = props;
|
||||
return (
|
||||
<Card>
|
||||
<div className={cn("h-32 bg-muted", pulse && "animate-pulse")} />
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<span className={cn("flex-1 bg-muted", pulse && "animate-pulse")}>
|
||||
|
||||
</span>
|
||||
<ProjectTierIndicator tier={ProjectTier.FREE} />
|
||||
</CardTitle>
|
||||
<CardDescription className={cn("bg-muted", pulse && "animate-pulse")}>
|
||||
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,107 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useParams, usePathname } from "next/navigation";
|
||||
|
||||
import { cn } from "@acme/ui";
|
||||
import * as Icons from "@acme/ui/icons";
|
||||
|
||||
const workspaceItems = [
|
||||
{
|
||||
title: "Projects",
|
||||
href: "/",
|
||||
icon: Icons.Post,
|
||||
},
|
||||
{
|
||||
title: "Billing",
|
||||
href: "/billing",
|
||||
icon: Icons.Billing,
|
||||
},
|
||||
{
|
||||
title: "Danger Zone",
|
||||
href: "/danger",
|
||||
icon: Icons.Warning,
|
||||
},
|
||||
{
|
||||
title: "Settings",
|
||||
href: "/settings",
|
||||
icon: Icons.Settings,
|
||||
},
|
||||
] as const;
|
||||
|
||||
const projectItems = [
|
||||
{
|
||||
title: "Dashboard",
|
||||
href: "/",
|
||||
icon: Icons.Dashboard,
|
||||
},
|
||||
{
|
||||
title: "API Keys",
|
||||
href: "/api-keys",
|
||||
icon: Icons.Key,
|
||||
},
|
||||
{
|
||||
title: "Danger Zone",
|
||||
href: "/danger",
|
||||
icon: Icons.Warning,
|
||||
},
|
||||
{
|
||||
title: "Settings",
|
||||
href: "/settings",
|
||||
icon: Icons.Settings,
|
||||
},
|
||||
] as const;
|
||||
|
||||
export function SidebarNav() {
|
||||
const params = useParams<{
|
||||
workspaceId: string;
|
||||
projectId?: string;
|
||||
}>();
|
||||
const path = usePathname();
|
||||
|
||||
// remove the workspaceId and projectId from the path when comparing active links in sidebar
|
||||
const pathname =
|
||||
path
|
||||
.replace(`/${params.workspaceId}`, "")
|
||||
.replace(`/${params.projectId}`, "") || "/";
|
||||
|
||||
const items = params.projectId ? projectItems : workspaceItems;
|
||||
if (!items?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<nav className="grid items-start gap-2">
|
||||
{items.map((item, index) => {
|
||||
const Icon = item.icon;
|
||||
|
||||
let fullPath = `/${params.workspaceId}`;
|
||||
if (params.projectId) {
|
||||
fullPath += `/${params.projectId}`;
|
||||
}
|
||||
fullPath += item.href;
|
||||
|
||||
return (
|
||||
item.href && (
|
||||
<Link
|
||||
key={index}
|
||||
href={fullPath}
|
||||
aria-disabled={"disabled" in item}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"group flex items-center rounded-md px-3 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground",
|
||||
pathname === item.href ? "bg-accent" : "transparent",
|
||||
"disabled" in item && "cursor-not-allowed opacity-80",
|
||||
)}
|
||||
>
|
||||
<Icon className="mr-2 h-4 w-4" />
|
||||
<span>{item.title}</span>
|
||||
</span>
|
||||
</Link>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@acme/ui/card";
|
||||
|
||||
import { DashboardShell } from "../../_components/dashboard-shell";
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<DashboardShell
|
||||
title="Billing"
|
||||
description="Manage your subscription and billing details"
|
||||
className="space-y-4"
|
||||
>
|
||||
<LoadingCard title="Subscription" />
|
||||
<LoadingCard title="Usage" />
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingCard(props: { title: string }) {
|
||||
return (
|
||||
<Card className="mt-4">
|
||||
<CardHeader>
|
||||
<CardTitle>{props.title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-24 animate-pulse rounded bg-muted" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@acme/ui/card";
|
||||
|
||||
import { api } from "~/trpc/server";
|
||||
import { DashboardShell } from "../../_components/dashboard-shell";
|
||||
import { SubscriptionForm } from "./subscription-form";
|
||||
|
||||
export const runtime = "edge";
|
||||
|
||||
export default function BillingPage() {
|
||||
return (
|
||||
<DashboardShell
|
||||
title="Billing"
|
||||
description="Manage your subscription and billing details"
|
||||
className="space-y-4"
|
||||
>
|
||||
<SubscriptionCard />
|
||||
|
||||
<UsageCard />
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
|
||||
async function SubscriptionCard() {
|
||||
const subscription = await api.auth.mySubscription.query();
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Subscription</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{subscription ? (
|
||||
<p>
|
||||
You are currently on the <strong>{subscription.plan}</strong> plan.
|
||||
Your subscription will renew on{" "}
|
||||
<strong>{subscription.endsAt?.toLocaleDateString()}</strong>.
|
||||
</p>
|
||||
) : (
|
||||
<p>You are not subscribed to any plan.</p>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<SubscriptionForm hasSubscription={!!subscription} />
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function UsageCard() {
|
||||
return (
|
||||
<Card className="mt-4">
|
||||
<CardHeader>
|
||||
<CardTitle>Usage</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>TODO</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@acme/ui/button";
|
||||
|
||||
import { api } from "~/trpc/client";
|
||||
|
||||
export function SubscriptionForm(props: { hasSubscription: boolean }) {
|
||||
async function createSession() {
|
||||
const { url } = await api.stripe.createSession.mutate({ planId: "" });
|
||||
if (url) window.location.href = url;
|
||||
}
|
||||
|
||||
return (
|
||||
<Button onClick={createSession}>
|
||||
{props.hasSubscription ? "Manage Subscription" : "Upgrade"}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuth } from "@clerk/nextjs";
|
||||
|
||||
import { Button } from "@acme/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@acme/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@acme/ui/dialog";
|
||||
import * as Icons from "@acme/ui/icons";
|
||||
import { useToast } from "@acme/ui/use-toast";
|
||||
|
||||
import { api } from "~/trpc/client";
|
||||
|
||||
export function DeleteWorkspace() {
|
||||
const toaster = useToast();
|
||||
const router = useRouter();
|
||||
const { orgId } = useAuth();
|
||||
|
||||
const title = "Delete workspace";
|
||||
const description = "This will delete the workspace and all of its data.";
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{title}</CardTitle>
|
||||
<CardDescription className="flex items-center">
|
||||
{description}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardFooter className="flex justify-between">
|
||||
<Dialog>
|
||||
<DialogTrigger asChild disabled={!orgId}>
|
||||
<Button variant="destructive">{title}</Button>
|
||||
</DialogTrigger>
|
||||
{!orgId && (
|
||||
<span className="mr-auto px-2 text-sm text-muted-foreground">
|
||||
You can not delete your personal workspace
|
||||
</span>
|
||||
)}
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex items-center font-bold text-destructive">
|
||||
<Icons.Warning className="mr-2 h-6 w-6" />
|
||||
<p>This action can not be reverted</p>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="secondary">Cancel</Button>
|
||||
</DialogClose>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await api.organization.deleteOrganization.mutate();
|
||||
toaster.toast({ title: "Workspace deleted" });
|
||||
router.push(`/dashboard`);
|
||||
} catch {
|
||||
toaster.toast({
|
||||
title: "The workspace could not be deleted",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
{`I'm sure. Delete this workspace`}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Button } from "@acme/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@acme/ui/card";
|
||||
|
||||
import { DashboardShell } from "../../_components/dashboard-shell";
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<DashboardShell
|
||||
title="Danger Zone"
|
||||
description="Do dangerous stuff here"
|
||||
className="space-y-6"
|
||||
>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Delete workspace</CardTitle>
|
||||
<CardDescription className="flex items-center">
|
||||
This will delete the workspace and all of its data.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardFooter className="flex justify-between">
|
||||
<Button variant="destructive" disabled>
|
||||
Delete workspace
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { DashboardShell } from "../../_components/dashboard-shell";
|
||||
import { DeleteWorkspace } from "./delete-workspace";
|
||||
|
||||
export const runtime = "edge";
|
||||
|
||||
export default function DangerZonePage() {
|
||||
return (
|
||||
<DashboardShell
|
||||
title="Danger Zone"
|
||||
description="Do dangerous stuff here"
|
||||
className="space-y-6"
|
||||
>
|
||||
<DeleteWorkspace />
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
22
apps/nextjs/src/app/(dashboard)/[workspaceId]/layout.tsx
Normal file
22
apps/nextjs/src/app/(dashboard)/[workspaceId]/layout.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { SidebarNav } from "./_components/sidebar";
|
||||
import { SyncActiveOrgFromUrl } from "./sync-active-org-from-url";
|
||||
|
||||
export default function WorkspaceLayout(props: {
|
||||
children: React.ReactNode;
|
||||
params: { workspaceId: string };
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{/* TODO: Nuke it when we can do it serverside in Clerk! */}
|
||||
<SyncActiveOrgFromUrl />
|
||||
<div className="container flex flex-1 gap-12">
|
||||
<aside className="hidden w-52 flex-col md:flex">
|
||||
<SidebarNav />
|
||||
</aside>
|
||||
<main className="flex flex-1 flex-col overflow-hidden">
|
||||
{props.children}
|
||||
</main>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
20
apps/nextjs/src/app/(dashboard)/[workspaceId]/loading.tsx
Normal file
20
apps/nextjs/src/app/(dashboard)/[workspaceId]/loading.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Button } from "@acme/ui/button";
|
||||
|
||||
import { DashboardShell } from "../_components/dashboard-shell";
|
||||
import { ProjectCard } from "./_components/project-card";
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<DashboardShell
|
||||
title="Projects"
|
||||
description="Projects for this workspace will show up here"
|
||||
headerAction={<Button disabled>Create a new project</Button>}
|
||||
>
|
||||
<ul className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<ProjectCard.Skeleton />
|
||||
<ProjectCard.Skeleton />
|
||||
<ProjectCard.Skeleton />
|
||||
</ul>
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
62
apps/nextjs/src/app/(dashboard)/[workspaceId]/page.tsx
Normal file
62
apps/nextjs/src/app/(dashboard)/[workspaceId]/page.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import Link from "next/link";
|
||||
import { Balancer } from "react-wrap-balancer";
|
||||
|
||||
import { Button } from "@acme/ui/button";
|
||||
|
||||
import { api } from "~/trpc/server";
|
||||
import { DashboardShell } from "../_components/dashboard-shell";
|
||||
import { ProjectCard } from "./_components/project-card";
|
||||
|
||||
export const runtime = "edge";
|
||||
|
||||
export default async function Page(props: { params: { workspaceId: string } }) {
|
||||
const { projects, limitReached } =
|
||||
await api.project.listByActiveWorkspace.query();
|
||||
|
||||
return (
|
||||
<DashboardShell
|
||||
title="Projects"
|
||||
description="Projects for this workspace will show up here"
|
||||
headerAction={
|
||||
limitReached ? (
|
||||
<Button className="min-w-max">Project limit reached</Button>
|
||||
) : (
|
||||
<Button className="min-w-max" asChild>
|
||||
<Link href={`/onboarding`}>Create a new project</Link>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
>
|
||||
<ul className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
{projects.map((project) => (
|
||||
<li key={project.id}>
|
||||
<ProjectCard
|
||||
project={project}
|
||||
workspaceId={props.params.workspaceId}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{projects.length === 0 && (
|
||||
<div className="relative">
|
||||
<ul className="grid select-none grid-cols-1 gap-4 opacity-40 md:grid-cols-3">
|
||||
<ProjectCard.Skeleton pulse={false} />
|
||||
<ProjectCard.Skeleton pulse={false} />
|
||||
<ProjectCard.Skeleton pulse={false} />
|
||||
</ul>
|
||||
<div className="absolute left-1/2 top-1/2 w-full -translate-x-1/2 -translate-y-1/2 text-center">
|
||||
<Balancer>
|
||||
<h2 className="text-2xl font-bold">
|
||||
This workspace has no projects yet
|
||||
</h2>
|
||||
<p className="text-lg text-muted-foreground">
|
||||
Create your first project to get started
|
||||
</p>
|
||||
</Balancer>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
"use client";
|
||||
|
||||
import type { InviteOrgMember } from "@acme/api/validators";
|
||||
import { inviteOrgMemberSchema, MEMBERSHIP } from "@acme/api/validators";
|
||||
import { Button } from "@acme/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@acme/ui/form";
|
||||
import { Input } from "@acme/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@acme/ui/select";
|
||||
import { useToast } from "@acme/ui/use-toast";
|
||||
|
||||
import { useZodForm } from "~/lib/zod-form";
|
||||
import { api } from "~/trpc/client";
|
||||
|
||||
export const InviteMemberForm = () => {
|
||||
const toaster = useToast();
|
||||
|
||||
const form = useZodForm({
|
||||
schema: inviteOrgMemberSchema,
|
||||
});
|
||||
|
||||
async function onSubmit(data: InviteOrgMember) {
|
||||
try {
|
||||
const member = await api.organization.inviteMember.mutate(data);
|
||||
toaster.toast({
|
||||
title: "Member invited",
|
||||
description: `An invitation to ${member.name} has been sent.`,
|
||||
});
|
||||
} catch (error) {
|
||||
toaster.toast({
|
||||
title: "Invitation failed",
|
||||
variant: "destructive",
|
||||
description: `An issue occured while inviting ${data.email}. Make sure they have an account, and try again.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email *</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="john@doe.com" />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The email address of the person you want to invite. They must
|
||||
have an account on this app.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="role"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Role *</FormLabel>
|
||||
<Select
|
||||
onValueChange={(val) =>
|
||||
field.onChange(
|
||||
val as (typeof MEMBERSHIP)[keyof typeof MEMBERSHIP],
|
||||
)
|
||||
}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a plan" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{Object.entries(MEMBERSHIP).map(([key, value]) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{key}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button type="submit">Create Project</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,191 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useOrganization } from "@clerk/nextjs";
|
||||
import type { Crop, PixelCrop } from "react-image-crop";
|
||||
import ReactCrop from "react-image-crop";
|
||||
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@acme/ui/avatar";
|
||||
import { Button } from "@acme/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@acme/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@acme/ui/dialog";
|
||||
import { Input } from "@acme/ui/input";
|
||||
import { useToast } from "@acme/ui/use-toast";
|
||||
|
||||
export function OrganizationImage(props: {
|
||||
name: string;
|
||||
image: string;
|
||||
orgId: string;
|
||||
}) {
|
||||
const [imgSrc, setImgSrc] = React.useState("");
|
||||
const [cropModalOpen, setCropModalOpen] = React.useState(false);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Organization Image</CardTitle>
|
||||
<CardDescription>
|
||||
Change your organization's avatar image
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<Avatar className="h-32 w-32">
|
||||
<AvatarImage src={props.image} />
|
||||
<AvatarFallback>{props.name.substring(0, 2)}</AvatarFallback>
|
||||
</Avatar>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter>
|
||||
<Dialog open={cropModalOpen} onOpenChange={setCropModalOpen}>
|
||||
<Input
|
||||
type="file"
|
||||
name="image"
|
||||
accept="image/*"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
setCropModalOpen(true);
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.addEventListener("load", () => {
|
||||
setImgSrc(reader.result?.toString() ?? "");
|
||||
});
|
||||
reader.readAsDataURL(file);
|
||||
}}
|
||||
/>
|
||||
<CropImageDialog
|
||||
imgSrc={imgSrc}
|
||||
close={() => setCropModalOpen(false)}
|
||||
/>
|
||||
</Dialog>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function CropImageDialog(props: { imgSrc: string; close: () => void }) {
|
||||
const [crop, setCrop] = React.useState<Crop>();
|
||||
const [storedCrop, setStoredCrop] = React.useState<PixelCrop>();
|
||||
const imageRef = React.useRef<HTMLImageElement>(null);
|
||||
|
||||
const [isUploading, setIsUploading] = React.useState(false);
|
||||
const { organization } = useOrganization();
|
||||
const { toast } = useToast();
|
||||
const router = useRouter();
|
||||
|
||||
async function saveImage() {
|
||||
if (!imageRef.current || !storedCrop) return;
|
||||
setIsUploading(true);
|
||||
const canvas = cropImage(imageRef.current, storedCrop);
|
||||
|
||||
const blob = await new Promise<Blob>((res, rej) => {
|
||||
canvas.toBlob((blob) => {
|
||||
blob ? res(blob) : rej("No blob");
|
||||
});
|
||||
});
|
||||
|
||||
await organization?.setLogo({ file: blob });
|
||||
toast({
|
||||
title: "Image updated",
|
||||
description: "Your organization image has been updated.",
|
||||
});
|
||||
|
||||
setIsUploading(false);
|
||||
router.refresh();
|
||||
props.close();
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Image</DialogTitle>
|
||||
<DialogDescription>
|
||||
Select the area of the image you would like to use
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<ReactCrop
|
||||
aspect={1}
|
||||
crop={crop}
|
||||
onChange={(_, percent) => setCrop(percent)}
|
||||
onComplete={(c) => setStoredCrop(c)}
|
||||
>
|
||||
{props.imgSrc && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img ref={imageRef} src={props.imgSrc} alt="Crop me" />
|
||||
)}
|
||||
</ReactCrop>
|
||||
|
||||
<DialogFooter>
|
||||
<Button onClick={saveImage}>
|
||||
{isUploading && (
|
||||
<div className="mr-1" role="status">
|
||||
<div className="h-3 w-3 animate-spin rounded-full border-2 border-background border-r-transparent" />
|
||||
</div>
|
||||
)}
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
|
||||
function cropImage(image: HTMLImageElement, crop: PixelCrop) {
|
||||
const canvas = document.createElement("canvas");
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) throw new Error("No 2d context");
|
||||
|
||||
const scaleX = image.naturalWidth / image.width;
|
||||
const scaleY = image.naturalHeight / image.height;
|
||||
const pixelRatio = window.devicePixelRatio;
|
||||
|
||||
canvas.width = Math.floor(crop.width * scaleX * pixelRatio);
|
||||
canvas.height = Math.floor(crop.height * scaleY * pixelRatio);
|
||||
|
||||
ctx.scale(pixelRatio, pixelRatio);
|
||||
ctx.imageSmoothingQuality = "high";
|
||||
|
||||
const cropX = crop.x * scaleX;
|
||||
const cropY = crop.y * scaleY;
|
||||
|
||||
const centerX = image.naturalWidth / 2;
|
||||
const centerY = image.naturalHeight / 2;
|
||||
|
||||
ctx.save();
|
||||
|
||||
ctx.translate(-cropX, -cropY);
|
||||
ctx.translate(centerX, centerY);
|
||||
ctx.translate(-centerX, -centerY);
|
||||
ctx.drawImage(
|
||||
image,
|
||||
0,
|
||||
0,
|
||||
image.naturalWidth,
|
||||
image.naturalHeight,
|
||||
0,
|
||||
0,
|
||||
image.naturalWidth,
|
||||
image.naturalHeight,
|
||||
);
|
||||
|
||||
ctx.restore();
|
||||
|
||||
return canvas;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import { use } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuth } from "@clerk/nextjs";
|
||||
import { formatRelative } from "date-fns";
|
||||
|
||||
import type { RouterOutputs } from "@acme/api";
|
||||
import { MEMBERSHIP } from "@acme/api/validators";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@acme/ui/avatar";
|
||||
import { Button } from "@acme/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@acme/ui/dropdown-menu";
|
||||
import * as Icons from "@acme/ui/icons";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@acme/ui/table";
|
||||
import { useToast } from "@acme/ui/use-toast";
|
||||
|
||||
import { api } from "~/trpc/client";
|
||||
|
||||
function formatMemberRole(role: string) {
|
||||
for (const [key, value] of Object.entries(MEMBERSHIP)) {
|
||||
if (value === role) {
|
||||
return key;
|
||||
}
|
||||
}
|
||||
return role;
|
||||
}
|
||||
|
||||
export function OrganizationMembers(props: {
|
||||
membersPromise: Promise<RouterOutputs["organization"]["listMembers"]>;
|
||||
}) {
|
||||
const members = use(props.membersPromise);
|
||||
const toaster = useToast();
|
||||
const router = useRouter();
|
||||
|
||||
const { orgRole } = useAuth();
|
||||
|
||||
// TODO: DataTable with actions
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="pointer-events-none bg-muted">
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Joined at</TableHead>
|
||||
<TableHead>Role</TableHead>
|
||||
<TableHead className="w-16"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{members.map((member) => (
|
||||
<TableRow key={member.id}>
|
||||
<TableCell className="flex items-center gap-2">
|
||||
<Avatar>
|
||||
<AvatarImage src={member.avatarUrl} alt={member.name} />
|
||||
<AvatarFallback>{member.name[0]}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex flex-col">
|
||||
<span>{member.name}</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{member.email}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{formatRelative(member.joinedAt, new Date())}</TableCell>
|
||||
<TableCell>{formatMemberRole(member.role)}</TableCell>
|
||||
<TableCell>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">Open menu</span>
|
||||
<Icons.Ellipsis className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
disabled={orgRole !== "admin"}
|
||||
onClick={async () => {
|
||||
try {
|
||||
const res = await api.organization.deleteMember.mutate({
|
||||
userId: member.id,
|
||||
});
|
||||
router.refresh();
|
||||
toaster.toast({
|
||||
title: `Deleted ${res.memberName} from the organization`,
|
||||
});
|
||||
} catch {
|
||||
toaster.toast({
|
||||
title: "Failed to delete member",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="text-destructive"
|
||||
>
|
||||
Delete member
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { useOrganization } from "@clerk/nextjs";
|
||||
|
||||
import { Button } from "@acme/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@acme/ui/card";
|
||||
import { Input } from "@acme/ui/input";
|
||||
import { Label } from "@acme/ui/label";
|
||||
import { useToast } from "@acme/ui/use-toast";
|
||||
|
||||
export function OrganizationName(props: { name: string; orgId: string }) {
|
||||
const { organization } = useOrganization();
|
||||
const [updating, setUpdating] = React.useState(false);
|
||||
const { toast } = useToast();
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Organization Name</CardTitle>
|
||||
<CardDescription>Change the name of your organization</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<form
|
||||
className="flex flex-col space-y-2"
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
const name = new FormData(e.currentTarget).get("name");
|
||||
if (!name || typeof name !== "string") return;
|
||||
setUpdating(true);
|
||||
await organization?.update({ name, slug: props.orgId });
|
||||
setUpdating(false);
|
||||
toast({
|
||||
title: "Organization name updated",
|
||||
description: "Your organization name has been updated.",
|
||||
});
|
||||
}}
|
||||
>
|
||||
<CardContent>
|
||||
<Label htmlFor="name">Name</Label>
|
||||
<Input id="name" name="name" defaultValue={props.name} />
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button type="submit" className="ml-auto">
|
||||
{updating && (
|
||||
<div className="mr-1" role="status">
|
||||
<div className="h-3 w-3 animate-spin rounded-full border-2 border-background border-r-transparent" />
|
||||
</div>
|
||||
)}
|
||||
Save
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
121
apps/nextjs/src/app/(dashboard)/[workspaceId]/settings/page.tsx
Normal file
121
apps/nextjs/src/app/(dashboard)/[workspaceId]/settings/page.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import { Suspense } from "react";
|
||||
import { notFound } from "next/navigation";
|
||||
import { auth, clerkClient, UserProfile } from "@clerk/nextjs";
|
||||
|
||||
import { Button } from "@acme/ui/button";
|
||||
import { Dialog, DialogContent, DialogTrigger } from "@acme/ui/dialog";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@acme/ui/tabs";
|
||||
|
||||
import { api } from "~/trpc/server";
|
||||
import { DashboardShell } from "../../_components/dashboard-shell";
|
||||
import { LoadingCard } from "../[projectId]/_components/loading-card";
|
||||
import { InviteMemberForm } from "./_components/invite-member-dialog";
|
||||
import { OrganizationImage } from "./_components/organization-image";
|
||||
import { OrganizationMembers } from "./_components/organization-members";
|
||||
import { OrganizationName } from "./_components/organization-name";
|
||||
|
||||
export const runtime = "edge";
|
||||
|
||||
export default function WorkspaceSettingsPage(props: {
|
||||
params: { workspaceId: string };
|
||||
}) {
|
||||
const { workspaceId } = props.params;
|
||||
const isOrg = workspaceId.startsWith("org_");
|
||||
|
||||
if (isOrg)
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<DashboardShell
|
||||
title="Organization"
|
||||
description="Manage your organization"
|
||||
>
|
||||
<Tabs defaultValue="general">
|
||||
<TabsList className="mb-2 w-full justify-start">
|
||||
<TabsTrigger value="general">General</TabsTrigger>
|
||||
<TabsTrigger value="members">Members</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="general" className="space-y-4">
|
||||
<OrganizationName orgId="org_123" name="" />
|
||||
<OrganizationImage orgId="org_123" name="" image="" />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</DashboardShell>
|
||||
}
|
||||
>
|
||||
<OrganizationSettingsPage />
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
return <UserSettingsPage />;
|
||||
}
|
||||
|
||||
async function OrganizationSettingsPage() {
|
||||
const { orgId } = auth();
|
||||
if (!orgId) notFound();
|
||||
|
||||
const org = await clerkClient.organizations.getOrganization({
|
||||
organizationId: orgId,
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
return (
|
||||
<DashboardShell title="Organization" description="Manage your organization">
|
||||
{/* TODO: Use URL instead of clientside tabs */}
|
||||
<Tabs defaultValue="general">
|
||||
<TabsList className="mb-2 w-full justify-start">
|
||||
<TabsTrigger value="general">General</TabsTrigger>
|
||||
<TabsTrigger value="members">Members</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="general" className="space-y-4">
|
||||
<OrganizationName orgId={org.id} name={org.name} />
|
||||
<OrganizationImage
|
||||
orgId={org.id}
|
||||
name={org.name}
|
||||
image={org.imageUrl}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="members" className="flex flex-col space-y-4">
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="self-end">Invite member</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<InviteMemberForm />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Suspense fallback={<LoadingCard title="Members" description="" />}>
|
||||
<OrganizationMembers
|
||||
membersPromise={api.organization.listMembers.query()}
|
||||
/>
|
||||
</Suspense>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
|
||||
function UserSettingsPage() {
|
||||
return (
|
||||
<DashboardShell title="Account" description="Manage your account details">
|
||||
<UserProfile
|
||||
appearance={{
|
||||
variables: {
|
||||
borderRadius: "var(--radius)",
|
||||
// colorBackground: "var(--background)",
|
||||
},
|
||||
elements: {
|
||||
// Main card element
|
||||
card: "shadow-none bg-background text-foreground",
|
||||
navbar: "hidden",
|
||||
navbarMobileMenuButton: "hidden",
|
||||
headerTitle: "hidden",
|
||||
headerSubtitle: "hidden",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useOrganizationList } from "@clerk/nextjs";
|
||||
|
||||
/**
|
||||
* I couldn't find a way to do this on the server :thinking: Clerk is adding support for this soon.
|
||||
* If I go to /[workspaceId]/**, I want to set the active organization to the workspaceId,
|
||||
* If it's a personal worksapce, set the organization to null, else find the organization by id
|
||||
* and set it to that.
|
||||
*/
|
||||
export function SyncActiveOrgFromUrl() {
|
||||
const { workspaceId } = useParams<{ workspaceId: string }>();
|
||||
const { setActive, userMemberships, isLoaded } = useOrganizationList({
|
||||
userMemberships: {
|
||||
infinite: true,
|
||||
},
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isLoaded || userMemberships.isLoading) return;
|
||||
|
||||
if (!workspaceId?.startsWith("org_")) {
|
||||
void setActive({ organization: null });
|
||||
return;
|
||||
}
|
||||
|
||||
const org = userMemberships?.data?.find(
|
||||
({ organization }) => organization.id === workspaceId,
|
||||
);
|
||||
|
||||
if (org) {
|
||||
void setActive(org);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [workspaceId, isLoaded]);
|
||||
|
||||
return null;
|
||||
}
|
||||
41
apps/nextjs/src/app/(dashboard)/_components/breadcrumbs.tsx
Normal file
41
apps/nextjs/src/app/(dashboard)/_components/breadcrumbs.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
import { cn } from "@acme/ui";
|
||||
|
||||
const items = {
|
||||
overview: "Overview",
|
||||
analytics: "Analytics",
|
||||
reports: "Reports",
|
||||
notifications: "Notifications",
|
||||
};
|
||||
|
||||
export function Breadcrumbs() {
|
||||
const pathname = usePathname();
|
||||
const [_, workspaceId, projectId, ...rest] = pathname.split("/");
|
||||
const baseUrl = `/${workspaceId}/${projectId}`;
|
||||
const restAsString = rest.join("/");
|
||||
|
||||
return (
|
||||
<div className="mb-4 inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground">
|
||||
{Object.entries(items).map(([key, value]) => {
|
||||
const isActive =
|
||||
key === restAsString || (key !== "" && restAsString.startsWith(key));
|
||||
return (
|
||||
<Link
|
||||
key={key}
|
||||
href={`${baseUrl}/${key}`}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
isActive && "bg-background text-foreground shadow-sm",
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import React from "react";
|
||||
|
||||
import { Breadcrumbs } from "./breadcrumbs";
|
||||
|
||||
export function DashboardShell(props: {
|
||||
title: string;
|
||||
description: React.ReactNode;
|
||||
breadcrumb?: boolean;
|
||||
headerAction?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<h1 className="font-cal text-xl font-semibold leading-none">
|
||||
{props.title}
|
||||
</h1>
|
||||
{typeof props.description === "string" ? (
|
||||
<h2 className="text-base text-muted-foreground">
|
||||
{props.description}
|
||||
</h2>
|
||||
) : (
|
||||
props.description
|
||||
)}
|
||||
</div>
|
||||
{props.headerAction}
|
||||
</div>
|
||||
{props.breadcrumb && <Breadcrumbs />}
|
||||
<div className={props.className}>{props.children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { addDays, format } from "date-fns";
|
||||
import { Calendar as CalendarIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@acme/ui";
|
||||
import { Button } from "@acme/ui/button";
|
||||
import { Calendar } from "@acme/ui/calendar";
|
||||
import type { DateRange } from "@acme/ui/calendar";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@acme/ui/popover";
|
||||
|
||||
export function CalendarDateRangePicker({
|
||||
className,
|
||||
align = "end",
|
||||
}: React.HTMLAttributes<HTMLDivElement> & {
|
||||
align?: "center" | "end" | "start";
|
||||
}) {
|
||||
const [date, setDate] = React.useState<DateRange | undefined>({
|
||||
from: new Date(2023, 0, 20),
|
||||
to: addDays(new Date(2023, 0, 20), 20),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={cn("grid gap-2", className)}>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
id="date"
|
||||
variant={"outline"}
|
||||
size="sm"
|
||||
className={cn(
|
||||
"w-[240px] justify-start text-left font-normal",
|
||||
!date && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{date?.from ? (
|
||||
date.to ? (
|
||||
<>
|
||||
{format(date.from, "LLL dd, y")} -{" "}
|
||||
{format(date.to, "LLL dd, y")}
|
||||
</>
|
||||
) : (
|
||||
format(date.from, "LLL dd, y")
|
||||
)
|
||||
) : (
|
||||
<span>Pick a date</span>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align={align}>
|
||||
<Calendar
|
||||
initialFocus
|
||||
mode="range"
|
||||
defaultMonth={date?.from}
|
||||
selected={date}
|
||||
onSelect={setDate}
|
||||
numberOfMonths={2}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
35
apps/nextjs/src/app/(dashboard)/_components/main-nav.tsx
Normal file
35
apps/nextjs/src/app/(dashboard)/_components/main-nav.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { cn } from "@acme/ui";
|
||||
|
||||
import { navItems } from "~/app/config";
|
||||
|
||||
// TODO: idx not needed as key when all items have unique hrefs
|
||||
// also, the active link should be filtered by href and not idx
|
||||
export function MainNav({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLElement>) {
|
||||
return (
|
||||
<nav
|
||||
className={cn(
|
||||
"hidden items-center space-x-4 md:flex lg:space-x-6",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{navItems.map((item, idx) => (
|
||||
<Link
|
||||
href={item.href}
|
||||
key={`${item.href}-${idx}`}
|
||||
className={cn(
|
||||
"text-sm font-medium transition-colors hover:text-primary",
|
||||
idx !== 0 && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{item.title}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
126
apps/nextjs/src/app/(dashboard)/_components/project-switcher.tsx
Normal file
126
apps/nextjs/src/app/(dashboard)/_components/project-switcher.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { Check, ChevronsUpDown, LayoutGrid } from "lucide-react";
|
||||
|
||||
import type { RouterOutputs } from "@acme/api";
|
||||
import { cn } from "@acme/ui";
|
||||
import { Button } from "@acme/ui/button";
|
||||
import {
|
||||
Command,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
} from "@acme/ui/command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@acme/ui/popover";
|
||||
|
||||
import { getRandomPatternStyle } from "~/lib/generate-pattern";
|
||||
|
||||
export function ProjectSwitcher(props: {
|
||||
projectsPromise: Promise<RouterOutputs["project"]["listByActiveWorkspace"]>;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
|
||||
const { projects } = React.use(props.projectsPromise);
|
||||
|
||||
const [switcherOpen, setSwitcherOpen] = React.useState(false);
|
||||
|
||||
const { workspaceId, projectId } = useParams<{
|
||||
workspaceId: string;
|
||||
projectId: string;
|
||||
}>();
|
||||
const activeProject = projects.find((p) => p.id === projectId);
|
||||
|
||||
if (!projectId) return null;
|
||||
if (!activeProject) {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
role="combobox"
|
||||
aria-expanded={switcherOpen}
|
||||
aria-label="Select a workspace"
|
||||
className="w-52 justify-between opacity-50"
|
||||
>
|
||||
Select a project
|
||||
<ChevronsUpDown className="ml-auto h-4 w-4 shrink-0" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<span className="mx-2 text-lg font-bold text-muted-foreground">/</span>
|
||||
|
||||
<Popover open={switcherOpen} onOpenChange={setSwitcherOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
role="combobox"
|
||||
aria-expanded={switcherOpen}
|
||||
aria-label="Select a project"
|
||||
className="relative w-52 justify-between"
|
||||
>
|
||||
<div
|
||||
style={getRandomPatternStyle(projectId)}
|
||||
className="absolute inset-1 opacity-25"
|
||||
/>
|
||||
<span className="z-10 font-semibold">{activeProject?.name}</span>
|
||||
<ChevronsUpDown className="ml-auto h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-52 p-0">
|
||||
<Command>
|
||||
<CommandList>
|
||||
<CommandInput placeholder="Search project..." />
|
||||
|
||||
{projects.map((project) => (
|
||||
<CommandItem
|
||||
key={project.id}
|
||||
onSelect={() => {
|
||||
setSwitcherOpen(false);
|
||||
router.push(`/${workspaceId}/${project.id}`);
|
||||
}}
|
||||
className="text-sm font-semibold"
|
||||
>
|
||||
<div
|
||||
style={getRandomPatternStyle(project.id)}
|
||||
className="absolute inset-1 opacity-25"
|
||||
/>
|
||||
{project.name}
|
||||
<Check
|
||||
className={cn(
|
||||
"ml-auto h-4 w-4",
|
||||
project.id === activeProject?.id
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandList>
|
||||
<CommandSeparator />
|
||||
<CommandList>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
onSelect={() => {
|
||||
router.push(`/${workspaceId}`);
|
||||
setSwitcherOpen(false);
|
||||
}}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<LayoutGrid className="mr-2 h-5 w-5" />
|
||||
Browse projects
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
}
|
||||
13
apps/nextjs/src/app/(dashboard)/_components/search.tsx
Normal file
13
apps/nextjs/src/app/(dashboard)/_components/search.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Input } from "@acme/ui/input";
|
||||
|
||||
export function Search() {
|
||||
return (
|
||||
<div>
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Search..."
|
||||
className="h-9 md:w-[100px] lg:w-[300px]"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useOrganization, useOrganizationList, useUser } from "@clerk/nextjs";
|
||||
import { toDecimal } from "dinero.js";
|
||||
import { Check, ChevronsUpDown, PlusCircle } from "lucide-react";
|
||||
|
||||
import type { PurchaseOrg } from "@acme/api/validators";
|
||||
import { purchaseOrgSchema } from "@acme/api/validators";
|
||||
import { cn } from "@acme/ui";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@acme/ui/avatar";
|
||||
import { Button } from "@acme/ui/button";
|
||||
import {
|
||||
Command,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
} from "@acme/ui/command";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@acme/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@acme/ui/form";
|
||||
import { Input } from "@acme/ui/input";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@acme/ui/popover";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@acme/ui/select";
|
||||
import { useToast } from "@acme/ui/use-toast";
|
||||
|
||||
import { currencySymbol } from "~/lib/currency";
|
||||
import { useZodForm } from "~/lib/zod-form";
|
||||
import { api } from "~/trpc/client";
|
||||
|
||||
export function WorkspaceSwitcher() {
|
||||
const router = useRouter();
|
||||
|
||||
const [switcherOpen, setSwitcherOpen] = React.useState(false);
|
||||
const [newOrgDialogOpen, setNewOrgDialogOpen] = React.useState(false);
|
||||
|
||||
const orgs = useOrganizationList({
|
||||
userMemberships: {
|
||||
infinite: true,
|
||||
},
|
||||
});
|
||||
const org = useOrganization();
|
||||
|
||||
const { user, isSignedIn, isLoaded } = useUser();
|
||||
if (isLoaded && !isSignedIn) throw new Error("How did you get here???");
|
||||
|
||||
const activeOrg = org.organization ?? user;
|
||||
if (
|
||||
!orgs.isLoaded ||
|
||||
!org.isLoaded ||
|
||||
!activeOrg ||
|
||||
orgs.userMemberships.isLoading
|
||||
) {
|
||||
// Skeleton loader
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
role="combobox"
|
||||
aria-expanded={switcherOpen}
|
||||
aria-label="Select a workspace"
|
||||
className="w-52 justify-between opacity-50"
|
||||
>
|
||||
<Avatar className="mr-2 h-5 w-5">
|
||||
<AvatarFallback>Ac</AvatarFallback>
|
||||
</Avatar>
|
||||
Select a workspace
|
||||
<ChevronsUpDown className="ml-auto h-4 w-4 shrink-0" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
const normalizedObject = {
|
||||
id: activeOrg.id,
|
||||
name: "name" in activeOrg ? activeOrg.name : activeOrg.fullName,
|
||||
image: activeOrg.imageUrl,
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={newOrgDialogOpen} onOpenChange={setNewOrgDialogOpen}>
|
||||
<Popover open={switcherOpen} onOpenChange={setSwitcherOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
role="combobox"
|
||||
aria-expanded={switcherOpen}
|
||||
aria-label="Select a workspace"
|
||||
className="w-52 justify-between"
|
||||
>
|
||||
<Avatar className="mr-2 h-5 w-5">
|
||||
<AvatarImage src={normalizedObject?.image ?? ""} />
|
||||
<AvatarFallback>
|
||||
{normalizedObject.name?.substring(0, 2)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
{normalizedObject.name}
|
||||
<ChevronsUpDown className="ml-auto h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-52 p-0">
|
||||
<Command>
|
||||
<CommandList>
|
||||
<CommandInput placeholder="Search workspace..." />
|
||||
<CommandGroup heading="Personal account">
|
||||
<CommandItem
|
||||
onSelect={async () => {
|
||||
if (!user?.id) return;
|
||||
normalizedObject.id = user.id ?? "";
|
||||
|
||||
await orgs.setActive?.({ organization: null });
|
||||
setSwitcherOpen(false);
|
||||
router.push(`/${user.id}`);
|
||||
}}
|
||||
className="cursor-pointer text-sm"
|
||||
>
|
||||
<Avatar className="mr-2 h-5 w-5">
|
||||
<AvatarImage
|
||||
src={user?.imageUrl}
|
||||
alt={user?.fullName ?? ""}
|
||||
/>
|
||||
<AvatarFallback>
|
||||
{`${user?.firstName?.[0]}${user?.lastName?.[0]}` ?? "JD"}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
{user?.fullName}
|
||||
<Check
|
||||
className={cn(
|
||||
"ml-auto h-4 w-4",
|
||||
org.organization === null ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
|
||||
<CommandGroup heading="Organizations">
|
||||
{orgs.userMemberships.data?.map(({ organization: org }) => (
|
||||
<CommandItem
|
||||
key={org.name}
|
||||
onSelect={async () => {
|
||||
await orgs.setActive({ organization: org });
|
||||
setSwitcherOpen(false);
|
||||
router.push(`/${org.id}`);
|
||||
}}
|
||||
className="cursor-pointer text-sm"
|
||||
>
|
||||
<Avatar className="mr-2 h-5 w-5">
|
||||
<AvatarImage
|
||||
src={org.imageUrl ?? "/images/placeholder.png"}
|
||||
alt={org.name}
|
||||
/>
|
||||
<AvatarFallback>
|
||||
{org.name.substring(0, 2)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
{org.name}
|
||||
<Check
|
||||
className={cn(
|
||||
"ml-auto h-4 w-4",
|
||||
normalizedObject?.id === org.id
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
<CommandSeparator />
|
||||
<CommandList>
|
||||
<CommandGroup>
|
||||
<DialogTrigger asChild>
|
||||
<CommandItem
|
||||
onSelect={() => {
|
||||
setSwitcherOpen(false);
|
||||
setNewOrgDialogOpen(true);
|
||||
}}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<PlusCircle className="mr-2 h-5 w-5" />
|
||||
Create Organization
|
||||
</CommandItem>
|
||||
</DialogTrigger>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<React.Suspense>
|
||||
<NewOrganizationDialog closeDialog={() => setNewOrgDialogOpen(false)} />
|
||||
</React.Suspense>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function NewOrganizationDialog(props: { closeDialog: () => void }) {
|
||||
const plans = React.use(api.stripe.plans.query());
|
||||
|
||||
const form = useZodForm({ schema: purchaseOrgSchema });
|
||||
|
||||
const toaster = useToast();
|
||||
|
||||
async function handleCreateOrg(data: PurchaseOrg) {
|
||||
const response = await api.stripe.purchaseOrg
|
||||
.mutate(data)
|
||||
.catch(() => ({ success: false as const }));
|
||||
|
||||
if (response.success) window.location.href = response.url;
|
||||
else
|
||||
toaster.toast({
|
||||
title: "Error",
|
||||
description:
|
||||
"There was an error setting up your organization. Please try again.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogContent>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(handleCreateOrg)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create organization</DialogTitle>
|
||||
<DialogDescription>
|
||||
Add a new organization to manage products and customers.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="orgName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Organization name *</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="Acme Inc." />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="planId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex justify-between">
|
||||
<FormLabel>Subscription plan *</FormLabel>
|
||||
<Link
|
||||
href="/pricing"
|
||||
className="text-xs text-muted-foreground hover:underline"
|
||||
>
|
||||
What's included in each plan?
|
||||
</Link>
|
||||
</div>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a plan" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{plans.map((plan) => (
|
||||
<SelectItem key={plan.priceId} value={plan.priceId}>
|
||||
<span className="font-medium">{plan.name}</span> -{" "}
|
||||
<span className="text-muted-foreground">
|
||||
{toDecimal(
|
||||
plan.price,
|
||||
({ value, currency }) =>
|
||||
`${currencySymbol(currency.code)}${value}`,
|
||||
)}{" "}
|
||||
per month
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => props.closeDialog()}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit">Continue</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
42
apps/nextjs/src/app/(dashboard)/layout.tsx
Normal file
42
apps/nextjs/src/app/(dashboard)/layout.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Suspense } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
import * as Icons from "@acme/ui/icons";
|
||||
|
||||
import { SiteFooter } from "~/components/footer";
|
||||
import { UserNav } from "~/components/user-nav";
|
||||
import { api } from "~/trpc/server";
|
||||
import { ProjectSwitcher } from "./_components/project-switcher";
|
||||
import { Search } from "./_components/search";
|
||||
import { WorkspaceSwitcher } from "./_components/workspace-switcher";
|
||||
|
||||
export default function DashboardLayout(props: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="min-h-screen overflow-hidden rounded-[0.5rem]">
|
||||
<nav className="border-b">
|
||||
<div className="flex h-16 items-center px-4 md:px-8">
|
||||
<Link href="/">
|
||||
<Icons.Logo />
|
||||
</Link>
|
||||
<span className="mx-2 text-lg font-bold text-muted-foreground">
|
||||
/
|
||||
</span>
|
||||
<WorkspaceSwitcher />
|
||||
<Suspense>
|
||||
<ProjectSwitcher
|
||||
projectsPromise={api.project.listByActiveWorkspace.query()}
|
||||
/>
|
||||
</Suspense>
|
||||
<div className="ml-auto flex items-center space-x-4">
|
||||
<Search />
|
||||
<UserNav />
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<main className="min-h-[calc(100vh-14rem)] flex-1 space-y-4 p-8 pt-6">
|
||||
{props.children}
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useEffect } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { motion } from "framer-motion";
|
||||
import { Balancer } from "react-wrap-balancer";
|
||||
|
||||
import { CreateApiKeyForm } from "../[workspaceId]/_components/create-api-key-form";
|
||||
|
||||
export function CreateApiKey() {
|
||||
const router = useRouter();
|
||||
const projectId = useSearchParams().get("projectId");
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectId) {
|
||||
router.push(`/onboarding`);
|
||||
}
|
||||
}, [projectId, router]);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="flex h-full w-full flex-col items-center justify-center"
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ duration: 0.3, type: "spring" }}
|
||||
>
|
||||
<motion.div
|
||||
variants={{
|
||||
show: {
|
||||
transition: {
|
||||
staggerChildren: 0.2,
|
||||
},
|
||||
},
|
||||
}}
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
className="flex flex-col rounded-xl bg-background/60 p-8"
|
||||
>
|
||||
<motion.h1
|
||||
className="mb-4 font-cal text-2xl font-bold transition-colors sm:text-3xl"
|
||||
variants={{
|
||||
hidden: { opacity: 0, x: 250 },
|
||||
show: {
|
||||
opacity: 1,
|
||||
x: 0,
|
||||
transition: { duration: 0.4, type: "spring" },
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Balancer>
|
||||
{`Next, let's create an API key for your project`}
|
||||
</Balancer>
|
||||
</motion.h1>
|
||||
<motion.div
|
||||
variants={{
|
||||
hidden: { opacity: 0, x: 100 },
|
||||
show: {
|
||||
opacity: 1,
|
||||
x: 0,
|
||||
transition: { duration: 0.4, type: "spring" },
|
||||
},
|
||||
}}
|
||||
>
|
||||
<CreateApiKeyForm
|
||||
projectId={projectId!}
|
||||
onSuccess={(apiKey) => {
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
searchParams.set("step", "done");
|
||||
searchParams.set("apiKey", apiKey);
|
||||
router.push(`/onboarding?${searchParams.toString()}`);
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { motion } from "framer-motion";
|
||||
import { Balancer } from "react-wrap-balancer";
|
||||
|
||||
import { CreateProjectForm } from "../[workspaceId]/_components/create-project-form";
|
||||
|
||||
export function CreateProject(props: { workspaceId: string }) {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="my-auto flex h-full w-full flex-col items-center justify-center"
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ duration: 0.3, type: "spring" }}
|
||||
>
|
||||
<motion.div
|
||||
variants={{
|
||||
show: {
|
||||
transition: {
|
||||
staggerChildren: 0.2,
|
||||
},
|
||||
},
|
||||
}}
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
className="flex flex-col rounded-xl bg-background/60 p-8"
|
||||
>
|
||||
<motion.h1
|
||||
className="mb-4 font-cal text-2xl font-bold transition-colors sm:text-3xl"
|
||||
variants={{
|
||||
hidden: { opacity: 0, x: 250 },
|
||||
show: {
|
||||
opacity: 1,
|
||||
x: 0,
|
||||
transition: { duration: 0.4, type: "spring" },
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Balancer>
|
||||
{`Let's start off by creating your first project`}
|
||||
</Balancer>
|
||||
</motion.h1>
|
||||
<motion.div
|
||||
variants={{
|
||||
hidden: { opacity: 0, x: 100 },
|
||||
show: {
|
||||
opacity: 1,
|
||||
x: 0,
|
||||
transition: { duration: 0.4, type: "spring" },
|
||||
},
|
||||
}}
|
||||
>
|
||||
<CreateProjectForm
|
||||
workspaceId={props.workspaceId}
|
||||
onSuccess={({ id }) => {
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
searchParams.set("step", "create-api-key");
|
||||
searchParams.set("projectId", id);
|
||||
router.push(`/onboarding?${searchParams.toString()}`);
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
60
apps/nextjs/src/app/(dashboard)/onboarding/done.tsx
Normal file
60
apps/nextjs/src/app/(dashboard)/onboarding/done.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { useEffect, useTransition } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
export function Done(props: { workspaceId: string }) {
|
||||
const router = useRouter();
|
||||
const search = useSearchParams();
|
||||
const step = search.get("step");
|
||||
const projectId = search.get("projectId");
|
||||
const apiKey = search.get("apiKey");
|
||||
|
||||
const [_, startTransition] = useTransition();
|
||||
useEffect(() => {
|
||||
if (step === "done") {
|
||||
setTimeout(() => {
|
||||
startTransition(() => {
|
||||
router.push(`${props.workspaceId}/${projectId}/overview`);
|
||||
router.refresh();
|
||||
});
|
||||
}, 2000);
|
||||
}
|
||||
}, [projectId, props.workspaceId, router, step, apiKey]);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="shadox-xl flex h-full w-full flex-col items-center justify-center bg-opacity-60 p-8"
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
initial={{ background: "transparent" }}
|
||||
animate={{ background: "var(--background)" }}
|
||||
transition={{ duration: 0.3, type: "spring" }}
|
||||
>
|
||||
<motion.div
|
||||
variants={{
|
||||
hidden: { opacity: 0, x: 250 },
|
||||
show: {
|
||||
opacity: 1,
|
||||
x: 0,
|
||||
transition: { duration: 0.4, type: "spring" },
|
||||
},
|
||||
}}
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
className="flex flex-col space-y-4 rounded-xl bg-background/60 p-8"
|
||||
>
|
||||
<h1 className="font-cal text-2xl font-bold transition-colors sm:text-3xl">
|
||||
You are all set!
|
||||
</h1>
|
||||
<p className="max-w-md text-muted-foreground transition-colors sm:text-lg">
|
||||
Congratulations, you have successfully created your first project.
|
||||
Check out the <Link href="/docs">docs</Link> to learn more on how to
|
||||
use the platform.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
You will be redirected to your project momentarily.
|
||||
</p>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
83
apps/nextjs/src/app/(dashboard)/onboarding/intro.tsx
Normal file
83
apps/nextjs/src/app/(dashboard)/onboarding/intro.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { motion } from "framer-motion";
|
||||
import { Balancer } from "react-wrap-balancer";
|
||||
|
||||
import { Button } from "@acme/ui/button";
|
||||
|
||||
import { useDebounce } from "~/lib/use-debounce";
|
||||
|
||||
export default function Intro() {
|
||||
const router = useRouter();
|
||||
|
||||
const showText = useDebounce(true, 800);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="flex h-full w-full flex-col items-center justify-center"
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ duration: 0.3, type: "spring" }}
|
||||
>
|
||||
{showText && (
|
||||
<motion.div
|
||||
variants={{
|
||||
show: {
|
||||
transition: {
|
||||
staggerChildren: 0.2,
|
||||
},
|
||||
},
|
||||
}}
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
className="mx-5 flex flex-col items-center space-y-6 text-center sm:mx-auto"
|
||||
>
|
||||
<motion.h1
|
||||
className="font-cal text-4xl font-bold transition-colors sm:text-5xl"
|
||||
variants={{
|
||||
hidden: { opacity: 0, y: 50 },
|
||||
show: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { duration: 0.4, type: "spring" },
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Balancer>Welcome to Acme Corp</Balancer>
|
||||
</motion.h1>
|
||||
<motion.p
|
||||
className="max-w-md text-muted-foreground transition-colors sm:text-lg"
|
||||
variants={{
|
||||
hidden: { opacity: 0, y: 50 },
|
||||
show: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { duration: 0.4, type: "spring" },
|
||||
},
|
||||
}}
|
||||
>
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do
|
||||
eiusmod tempor incididunt.
|
||||
</motion.p>
|
||||
<motion.div
|
||||
variants={{
|
||||
hidden: { opacity: 0, y: 50 },
|
||||
show: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { duration: 0.4, type: "spring" },
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
size="lg"
|
||||
onClick={() => router.push("/onboarding?step=create-project")}
|
||||
>
|
||||
Get Started
|
||||
</Button>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { AnimatePresence } from "framer-motion";
|
||||
|
||||
import { CreateApiKey } from "./create-api-key";
|
||||
import { CreateProject } from "./create-project";
|
||||
import { Done } from "./done";
|
||||
import Intro from "./intro";
|
||||
|
||||
export function Onboarding(props: { workspaceId: string }) {
|
||||
const search = useSearchParams();
|
||||
const step = search.get("step");
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex h-[calc(100vh-14rem)] w-full max-w-screen-sm flex-col items-center">
|
||||
<AnimatePresence mode="wait">
|
||||
{!step && <Intro key="intro" />}
|
||||
{step === "create-project" && (
|
||||
<CreateProject workspaceId={props.workspaceId} />
|
||||
)}
|
||||
{step === "create-api-key" && <CreateApiKey />}
|
||||
{step === "done" && <Done workspaceId={props.workspaceId} />}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
17
apps/nextjs/src/app/(dashboard)/onboarding/page.tsx
Normal file
17
apps/nextjs/src/app/(dashboard)/onboarding/page.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { auth } from "@clerk/nextjs";
|
||||
|
||||
import { Onboarding } from "./multi-step-form";
|
||||
|
||||
export const runtime = "edge";
|
||||
|
||||
export default function OnboardingPage() {
|
||||
const { orgId, userId } = auth();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Onboarding workspaceId={orgId ?? userId!} />
|
||||
|
||||
<div className="absolute inset-0 top-12 -z-10 bg-cover bg-center" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
60
apps/nextjs/src/app/(marketing)/layout.tsx
Normal file
60
apps/nextjs/src/app/(marketing)/layout.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Suspense } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import Link from "next/link";
|
||||
import { auth } from "@clerk/nextjs";
|
||||
|
||||
import { buttonVariants } from "@acme/ui/button";
|
||||
import * as Icons from "@acme/ui/icons";
|
||||
|
||||
import { siteConfig } from "~/app/config";
|
||||
import { SiteFooter } from "~/components/footer";
|
||||
import { MobileDropdown } from "~/components/mobile-nav";
|
||||
import { MainNav } from "../(dashboard)/_components/main-nav";
|
||||
|
||||
export default function MarketingLayout(props: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<nav className="container z-50 flex h-16 items-center border-b bg-background">
|
||||
<div className="mr-8 hidden items-center md:flex">
|
||||
<Icons.Logo className="mr-2 h-6 w-6" />
|
||||
<span className="text-lg font-bold tracking-tight">
|
||||
{siteConfig.name}
|
||||
</span>
|
||||
</div>
|
||||
<MobileDropdown />
|
||||
<MainNav />
|
||||
<div className="ml-auto flex items-center space-x-4">
|
||||
<Suspense>
|
||||
<DashboardLink />
|
||||
</Suspense>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main className="flex-1">{props.children}</main>
|
||||
<SiteFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DashboardLink() {
|
||||
const { userId, orgId } = auth();
|
||||
|
||||
if (!userId) {
|
||||
return (
|
||||
<Link href="/signin" className={buttonVariants({ variant: "outline" })}>
|
||||
Sign In
|
||||
<Icons.ChevronRight className="ml-1 h-4 w-4" />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/${orgId ?? userId}`}
|
||||
className={buttonVariants({ variant: "outline" })}
|
||||
>
|
||||
Dashboard
|
||||
<Icons.ChevronRight className="ml-1 h-4 w-4" />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
93
apps/nextjs/src/app/(marketing)/page.tsx
Normal file
93
apps/nextjs/src/app/(marketing)/page.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import { Balancer } from "react-wrap-balancer";
|
||||
|
||||
import { cn } from "@acme/ui";
|
||||
import { buttonVariants } from "@acme/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@acme/ui/card";
|
||||
import * as Icons from "@acme/ui/icons";
|
||||
|
||||
import { marketingFeatures, siteConfig } from "~/app/config";
|
||||
|
||||
export const runtime = "edge";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<main className="flex min-h-screen w-full flex-col items-center justify-center pt-48">
|
||||
<div className="z-10 min-h-[50vh] w-full max-w-4xl px-5 xl:px-0">
|
||||
{/* <a
|
||||
href="https://twitter.com/steventey/status/1613928948915920896"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="mx-auto mb-5 flex max-w-fit animate-fade-up items-center justify-center space-x-2 overflow-hidden rounded-full bg-sky-100 px-7 py-2 transition-colors hover:bg-sky-200"
|
||||
>
|
||||
<Icons.twitter className="h-5 w-5 text-sky-500" />
|
||||
<p className="text-sm font-semibold text-sky-500">
|
||||
Introducing Acme Corp
|
||||
</p>
|
||||
</a> */}
|
||||
<h1
|
||||
className="animate-fade-up bg-gradient-to-br from-foreground to-muted-foreground bg-clip-text text-center text-4xl font-bold tracking-[-0.02em] text-transparent opacity-0 drop-shadow-sm md:text-7xl/[5rem]"
|
||||
style={{ animationDelay: "0.20s", animationFillMode: "forwards" }}
|
||||
>
|
||||
<Balancer>Your all-in-one, enterprise ready starting point</Balancer>
|
||||
</h1>
|
||||
<p
|
||||
className="mt-6 animate-fade-up text-center text-muted-foreground/80 opacity-0 md:text-xl"
|
||||
style={{ animationDelay: "0.30s", animationFillMode: "forwards" }}
|
||||
>
|
||||
<Balancer>
|
||||
Acme Corp is a Next.js starter kit that includes everything you need
|
||||
to build a modern web application. Mobile application preconfigured,
|
||||
ready to go.
|
||||
</Balancer>
|
||||
</p>
|
||||
<div
|
||||
className="mx-auto mt-6 flex animate-fade-up items-center justify-center space-x-5 opacity-0"
|
||||
style={{ animationDelay: "0.40s", animationFillMode: "forwards" }}
|
||||
>
|
||||
<a
|
||||
className={cn(buttonVariants({ variant: "default" }))}
|
||||
href={siteConfig.github}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Icons.GitHub className="mr-1 h-4 w-4" />
|
||||
<span>Star on GitHub</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="my-16 w-full max-w-screen-lg animate-fade-up gap-5 border-t p-5 xl:px-0">
|
||||
<h2 className="pt-4 text-center text-3xl font-bold md:text-4xl">
|
||||
What's included?
|
||||
</h2>
|
||||
|
||||
<p className="pb-8 pt-4 text-center text-lg">
|
||||
<Balancer>
|
||||
This repo comes fully stacked with everything you need for your
|
||||
enterprise startup. Stop worrying about boilerplate integrations and
|
||||
start building your product today!
|
||||
</Balancer>
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 gap-5 md:grid-cols-3">
|
||||
{marketingFeatures.map((feature) => (
|
||||
<Card key={feature.title} className={cn("p-2")}>
|
||||
<CardHeader>{feature.icon}</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<CardTitle>{feature.title}</CardTitle>
|
||||
<CardDescription className="mt-2">
|
||||
{feature.body}
|
||||
</CardDescription>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
76
apps/nextjs/src/app/(marketing)/pricing/page.tsx
Normal file
76
apps/nextjs/src/app/(marketing)/pricing/page.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { toDecimal } from "dinero.js";
|
||||
import { CheckCircle2 } from "lucide-react";
|
||||
import { Balancer } from "react-wrap-balancer";
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@acme/ui/card";
|
||||
|
||||
import { currencySymbol } from "~/lib/currency";
|
||||
import type { RouterOutputs } from "~/trpc/server";
|
||||
import { api } from "~/trpc/server";
|
||||
import { SubscribeNow } from "./subscribe-now";
|
||||
|
||||
// FIXME: Run this in Edge runtime - currently got some weird transforming error with Dinero.js + Superjson
|
||||
// export const runtime = "edge";
|
||||
|
||||
export default async function PricingPage() {
|
||||
const plans = await api.stripe.plans.query();
|
||||
|
||||
return (
|
||||
<main className="flex w-full flex-col items-center justify-center pt-16">
|
||||
<div className="z-10 min-h-[50vh] w-full max-w-7xl px-5 xl:px-0">
|
||||
<h1 className="font-cal text-7xl/[5rem]">Pricing</h1>
|
||||
<Balancer className="text-2xl">
|
||||
Simple pricing for all your needs. No hidden fees, no surprises.
|
||||
</Balancer>
|
||||
|
||||
<div className="my-8 grid grid-cols-1 gap-8 md:grid-cols-2">
|
||||
{plans.map((plan) => (
|
||||
<PricingCard key={plan.priceId} plan={plan} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function PricingCard(props: {
|
||||
plan: RouterOutputs["stripe"]["plans"][number];
|
||||
}) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{props.plan.name}</CardTitle>
|
||||
<div className="text-2xl font-bold">
|
||||
{toDecimal(
|
||||
props.plan.price,
|
||||
({ value, currency }) => `${currencySymbol(currency.code)}${value}`,
|
||||
)}
|
||||
<span className="text-base font-normal"> / month</span>
|
||||
</div>{" "}
|
||||
<CardDescription>{props.plan.description}</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<ul className="flex flex-col px-6 pb-6">
|
||||
{props.plan.preFeatures && (
|
||||
<li className="flex items-center pb-1">{props.plan.preFeatures}</li>
|
||||
)}
|
||||
{props.plan.features.map((feature) => (
|
||||
<li key={feature} className="flex items-center">
|
||||
<CheckCircle2 className="mr-2 h-6 w-6 fill-primary text-primary-foreground" />
|
||||
{feature}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<CardFooter>
|
||||
<SubscribeNow planId={props.plan.priceId} />
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
28
apps/nextjs/src/app/(marketing)/pricing/subscribe-now.tsx
Normal file
28
apps/nextjs/src/app/(marketing)/pricing/subscribe-now.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useSession } from "@clerk/nextjs";
|
||||
|
||||
import { Button } from "@acme/ui/button";
|
||||
|
||||
import { api } from "~/trpc/client";
|
||||
|
||||
export function SubscribeNow(props: { planId: string }) {
|
||||
const router = useRouter();
|
||||
const session = useSession();
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={async () => {
|
||||
if (!session.isSignedIn) router.push("/signin");
|
||||
|
||||
const billingPortal = await api.stripe.createSession.mutate({
|
||||
planId: props.planId,
|
||||
});
|
||||
if (billingPortal.success) router.push(billingPortal.url);
|
||||
}}
|
||||
>
|
||||
Subscribe now
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user