Cleanup
This commit is contained in:
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"12bb71342c6255bbf50437ec8f4441c083f47cdb74bd89160c15e4f43e52a1cb": true,
|
||||
"40b842e832070c58deac6aa9e08fa459302ee3f9da492c7e77d93d2fbf4a56fd": true
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
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;
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 10 KiB |
@@ -1,15 +0,0 @@
|
||||
/** @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"),
|
||||
],
|
||||
};
|
||||
};
|
||||
@@ -1,31 +0,0 @@
|
||||
{
|
||||
"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": {}
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
// 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;
|
||||
@@ -1,29 +0,0 @@
|
||||
// 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",
|
||||
});
|
||||
@@ -1,68 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
// 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>
|
||||
// );
|
||||
// }
|
||||
@@ -1,22 +0,0 @@
|
||||
// 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>
|
||||
// );
|
||||
// }
|
||||
@@ -1,3 +0,0 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
1
apps/expo/src/types/nativewind-env.d.ts
vendored
1
apps/expo/src/types/nativewind-env.d.ts
vendored
@@ -1 +0,0 @@
|
||||
/// <reference types="nativewind/types" />
|
||||
@@ -1,76 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
// @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;
|
||||
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"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
4
apps/nextjs/.vscode/settings.json
vendored
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"typescript.tsdk": "../../node_modules/typescript/lib",
|
||||
"typescript.enablePromptUseWorkspaceTsdk": true
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
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);
|
||||
@@ -1,73 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
// @ts-expect-error - No types for postcss
|
||||
module.exports = require("@acme/tailwind-config/postcss");
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 203 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 313 KiB |
@@ -1,13 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 923 B |
@@ -1,37 +0,0 @@
|
||||
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" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,357 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,200 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
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`);
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
};
|
||||
@@ -1,65 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,107 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
};
|
||||
@@ -1,191 +0,0 @@
|
||||
"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;
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
"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;
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
"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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,325 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
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" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
export const runtime = "edge";
|
||||
|
||||
<main className="container">
|
||||
|
||||
# Privacy
|
||||
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec bibendum pretium congue. Vivamus hendrerit, sem id fringilla accumsan, ex ex suscipit tellus, vitae volutpat enim velit vel erat. Quisque scelerisque maximus rutrum. Curabitur non suscipit augue, sit amet lobortis libero. Sed semper justo sit amet congue semper. Cras imperdiet ultricies faucibus. Sed placerat, urna ut vehicula aliquet, libero urna imperdiet odio, in condimentum felis arcu sit amet nibh. Curabitur eu lectus sit amet est iaculis posuere at a orci. In gravida nunc vel orci pharetra, id vulputate ligula placerat. Praesent fringilla massa ac urna tempus, eu venenatis turpis aliquam. Pellentesque nec dapibus velit. Aenean sagittis nisi purus, vitae vehicula sapien mattis sit amet. Etiam commodo sit amet quam eleifend commodo. Nunc blandit commodo urna at porta.
|
||||
|
||||
Sed sed turpis justo. Aenean congue nisi ligula, id vulputate nibh ullamcorper ac. Maecenas molestie cursus blandit. In dolor erat, venenatis rutrum porta nec, placerat ut velit. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Fusce placerat, purus sit amet accumsan condimentum, lorem tortor semper felis, vel maximus risus nibh vel nisi. Nam elit diam, sagittis id egestas non, volutpat vel arcu. Ut congue at urna vitae vehicula. Curabitur nec maximus magna.
|
||||
|
||||
Ut sed elementum nisi. Morbi vitae faucibus nisi. Nulla magna purus, blandit sed congue et, posuere ut est. Ut scelerisque risus est, ac tincidunt risus dignissim et. Praesent in elementum sapien. Proin tortor augue, tempus vel sapien non, commodo laoreet ante. Integer id semper turpis. Sed a lobortis orci, sed vulputate odio. Suspendisse eget eros a nibh dictum euismod. Sed congue, dolor vel finibus malesuada, risus massa accumsan nisi, in tincidunt ante nibh et velit.
|
||||
|
||||
Praesent scelerisque lorem quis erat tempus rutrum vitae at enim. Nam vestibulum diam nec euismod sagittis. Mauris ac metus congue, aliquam nibh sit amet, accumsan ex. Nulla vitae efficitur felis, quis hendrerit lacus. Donec id arcu fermentum, commodo enim in, accumsan nisl. Fusce euismod faucibus velit, ac vulputate ex faucibus et. Donec imperdiet egestas ornare. Aenean sit amet varius erat, vel ullamcorper dui. Curabitur nisl mauris, egestas eget ipsum eget, semper aliquet justo. Donec a commodo nunc. Duis ac ullamcorper arcu, tristique molestie enim. Donec tincidunt gravida eros, ut viverra nunc tristique ut. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae;
|
||||
|
||||
Morbi rutrum libero vel suscipit dapibus. Suspendisse pellentesque et mauris vel mattis. Nunc non suscipit est. Nullam id enim accumsan, commodo magna vel, elementum lectus. Integer ut orci dolor. Interdum et malesuada fames ac ante ipsum primis in faucibus. Etiam rutrum dignissim metus, in eleifend sapien dictum at. Praesent ac libero purus. Cras vel tortor in lectus placerat porttitor in in massa. Vestibulum dui turpis, cursus vitae posuere eget, accumsan ac nisi. Duis suscipit tortor augue, at iaculis metus consequat eget. Duis id sapien arcu.
|
||||
|
||||
</main>
|
||||
@@ -1,17 +0,0 @@
|
||||
export const runtime = "edge";
|
||||
|
||||
<main className="container">
|
||||
|
||||
# Terms
|
||||
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec bibendum pretium congue. Vivamus hendrerit, sem id fringilla accumsan, ex ex suscipit tellus, vitae volutpat enim velit vel erat. Quisque scelerisque maximus rutrum. Curabitur non suscipit augue, sit amet lobortis libero. Sed semper justo sit amet congue semper. Cras imperdiet ultricies faucibus. Sed placerat, urna ut vehicula aliquet, libero urna imperdiet odio, in condimentum felis arcu sit amet nibh. Curabitur eu lectus sit amet est iaculis posuere at a orci. In gravida nunc vel orci pharetra, id vulputate ligula placerat. Praesent fringilla massa ac urna tempus, eu venenatis turpis aliquam. Pellentesque nec dapibus velit. Aenean sagittis nisi purus, vitae vehicula sapien mattis sit amet. Etiam commodo sit amet quam eleifend commodo. Nunc blandit commodo urna at porta.
|
||||
|
||||
Sed sed turpis justo. Aenean congue nisi ligula, id vulputate nibh ullamcorper ac. Maecenas molestie cursus blandit. In dolor erat, venenatis rutrum porta nec, placerat ut velit. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Fusce placerat, purus sit amet accumsan condimentum, lorem tortor semper felis, vel maximus risus nibh vel nisi. Nam elit diam, sagittis id egestas non, volutpat vel arcu. Ut congue at urna vitae vehicula. Curabitur nec maximus magna.
|
||||
|
||||
Ut sed elementum nisi. Morbi vitae faucibus nisi. Nulla magna purus, blandit sed congue et, posuere ut est. Ut scelerisque risus est, ac tincidunt risus dignissim et. Praesent in elementum sapien. Proin tortor augue, tempus vel sapien non, commodo laoreet ante. Integer id semper turpis. Sed a lobortis orci, sed vulputate odio. Suspendisse eget eros a nibh dictum euismod. Sed congue, dolor vel finibus malesuada, risus massa accumsan nisi, in tincidunt ante nibh et velit.
|
||||
|
||||
Praesent scelerisque lorem quis erat tempus rutrum vitae at enim. Nam vestibulum diam nec euismod sagittis. Mauris ac metus congue, aliquam nibh sit amet, accumsan ex. Nulla vitae efficitur felis, quis hendrerit lacus. Donec id arcu fermentum, commodo enim in, accumsan nisl. Fusce euismod faucibus velit, ac vulputate ex faucibus et. Donec imperdiet egestas ornare. Aenean sit amet varius erat, vel ullamcorper dui. Curabitur nisl mauris, egestas eget ipsum eget, semper aliquet justo. Donec a commodo nunc. Duis ac ullamcorper arcu, tristique molestie enim. Donec tincidunt gravida eros, ut viverra nunc tristique ut. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae;
|
||||
|
||||
Morbi rutrum libero vel suscipit dapibus. Suspendisse pellentesque et mauris vel mattis. Nunc non suscipit est. Nullam id enim accumsan, commodo magna vel, elementum lectus. Integer ut orci dolor. Interdum et malesuada fames ac ante ipsum primis in faucibus. Etiam rutrum dignissim metus, in eleifend sapien dictum at. Praesent ac libero purus. Cras vel tortor in lectus placerat porttitor in in massa. Vestibulum dui turpis, cursus vitae posuere eget, accumsan ac nisi. Duis suscipit tortor augue, at iaculis metus consequat eget. Duis id sapien arcu.
|
||||
|
||||
</main>
|
||||
@@ -1,30 +0,0 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { getAuth } from "@clerk/nextjs/server";
|
||||
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
|
||||
|
||||
import { createTRPCContext } from "@acme/api";
|
||||
import { edgeRouter } from "@acme/api/edge";
|
||||
|
||||
export const runtime = "edge";
|
||||
|
||||
const createContext = async (req: NextRequest) => {
|
||||
return createTRPCContext({
|
||||
headers: req.headers,
|
||||
auth: getAuth(req),
|
||||
req,
|
||||
});
|
||||
};
|
||||
|
||||
const handler = (req: NextRequest) =>
|
||||
fetchRequestHandler({
|
||||
endpoint: "/api/trpc/edge",
|
||||
router: edgeRouter,
|
||||
req: req,
|
||||
createContext: () => createContext(req),
|
||||
onError: ({ error, path }) => {
|
||||
console.log("Error in tRPC handler (edge) on path", path);
|
||||
console.error(error);
|
||||
},
|
||||
});
|
||||
|
||||
export { handler as GET, handler as POST };
|
||||
@@ -1,31 +0,0 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { getAuth } from "@clerk/nextjs/server";
|
||||
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
|
||||
|
||||
import { createTRPCContext } from "@acme/api";
|
||||
import { lambdaRouter } from "@acme/api/lambda";
|
||||
|
||||
// Stripe is incompatible with Edge runtimes due to using Node.js events
|
||||
// export const runtime = "edge";
|
||||
|
||||
const createContext = async (req: NextRequest) => {
|
||||
return createTRPCContext({
|
||||
headers: req.headers,
|
||||
auth: getAuth(req),
|
||||
req,
|
||||
});
|
||||
};
|
||||
|
||||
const handler = (req: NextRequest) =>
|
||||
fetchRequestHandler({
|
||||
endpoint: "/api/trpc/lambda",
|
||||
router: lambdaRouter,
|
||||
req: req,
|
||||
createContext: () => createContext(req),
|
||||
onError: ({ error, path }) => {
|
||||
console.log("Error in tRPC handler (lambda) on path", path);
|
||||
console.error(error);
|
||||
},
|
||||
});
|
||||
|
||||
export { handler as GET, handler as POST };
|
||||
@@ -1,28 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
import { handleEvent, stripe } from "@acme/stripe";
|
||||
|
||||
import { env } from "~/env.mjs";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const payload = await req.text();
|
||||
const signature = req.headers.get("Stripe-Signature")!;
|
||||
|
||||
try {
|
||||
const event = stripe.webhooks.constructEvent(
|
||||
payload,
|
||||
signature,
|
||||
env.STRIPE_WEBHOOK_SECRET,
|
||||
);
|
||||
|
||||
await handleEvent(event);
|
||||
|
||||
console.log("✅ Handled Stripe Event", event.type);
|
||||
return NextResponse.json({ received: true }, { status: 200 });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
console.log(`❌ Error when handling Stripe Event: ${message}`);
|
||||
return NextResponse.json({ error: message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
import type { Route } from "next";
|
||||
import { Component, CreditCard, Globe } from "lucide-react";
|
||||
|
||||
import * as Icons from "@acme/ui/icons";
|
||||
|
||||
export const siteConfig = {
|
||||
name: "Acme Corp",
|
||||
description:
|
||||
"Next.js starter kit that includes everything you need to build a modern web application. Mobile application preconfigured, ready to go.",
|
||||
github: "https://github.com/juliusmarminge/acme-corp",
|
||||
twitter: "https://twitter.com/jullerino",
|
||||
};
|
||||
|
||||
export const navItems = [
|
||||
{
|
||||
href: "/dashboard",
|
||||
title: "Overview",
|
||||
},
|
||||
{
|
||||
href: "/pricing",
|
||||
title: "Pricing",
|
||||
},
|
||||
{
|
||||
href: "/dashboard",
|
||||
title: "Products",
|
||||
},
|
||||
{
|
||||
href: "/dashboard",
|
||||
title: "Settings",
|
||||
},
|
||||
] satisfies { href: Route; title: string }[];
|
||||
|
||||
export const marketingFeatures = [
|
||||
{
|
||||
icon: <Component className="h-10 w-10" />,
|
||||
title: "UI Package",
|
||||
body: (
|
||||
<>
|
||||
A UI package with all the components you need for your next application.
|
||||
Built by the wonderful{" "}
|
||||
<a
|
||||
href="https://ui.shadcn.com"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
Shadcn
|
||||
</a>
|
||||
.
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
icon: <Icons.ClerkWide className="h-10" />,
|
||||
title: "Authentication",
|
||||
body: (
|
||||
<>
|
||||
Protect pages and API routes throughout your entire app using{" "}
|
||||
<a
|
||||
href="https://clerk.com"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
Clerk
|
||||
</a>
|
||||
.
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
icon: <Icons.Mdx className="h-10" />,
|
||||
title: "MDX",
|
||||
body: (
|
||||
<>
|
||||
Preconfigured MDX as Server Components. MDX is the best way to write
|
||||
contentful pages.
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
icon: (
|
||||
<div className="flex gap-3 self-start">
|
||||
<Icons.Nextjs className="h-10 w-10" />
|
||||
<Icons.React className="h-10 w-10" />
|
||||
</div>
|
||||
),
|
||||
title: "Next.js 13 & React 18",
|
||||
body: (
|
||||
<>
|
||||
Latest features from Next 13 using the brand new App Router with full
|
||||
React 18 support including streaming.
|
||||
</>
|
||||
),
|
||||
},
|
||||
|
||||
{
|
||||
icon: (
|
||||
<div className="flex gap-3 self-start">
|
||||
<Icons.TRPC className="h-10 w-10" />
|
||||
<Icons.Kysely className="h-10 w-10" />
|
||||
<Icons.Prisma className="h-10 w-10" />
|
||||
</div>
|
||||
),
|
||||
title: "Full-stack Typesafety",
|
||||
body: (
|
||||
<>
|
||||
Full-stack Typesafety with{" "}
|
||||
<a
|
||||
href="https://trpc.io"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
tRPC
|
||||
</a>
|
||||
. Typesafe database querying using{" "}
|
||||
<a
|
||||
href="https://kysely.dev"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
Kysely
|
||||
</a>{" "}
|
||||
and{" "}
|
||||
<a
|
||||
href="https://prisma.io"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
Prisma
|
||||
</a>
|
||||
.
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
icon: <Globe className="h-10 w-10" />,
|
||||
title: "Edge Compute",
|
||||
body: (
|
||||
<>
|
||||
Ready to deploy on Edge functions to ensure a blazingly fast application
|
||||
with optimal UX.
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
icon: <CreditCard className="h-10 w-10" />,
|
||||
title: "Payments",
|
||||
body: (
|
||||
<>
|
||||
Accept payments with{" "}
|
||||
<a
|
||||
href="https://stripe.com"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
Stripe
|
||||
</a>
|
||||
.
|
||||
</>
|
||||
),
|
||||
},
|
||||
];
|
||||
@@ -1,70 +0,0 @@
|
||||
import "react-image-crop/dist/ReactCrop.css";
|
||||
import "~/styles/globals.css";
|
||||
|
||||
import { Inter } from "next/font/google";
|
||||
import LocalFont from "next/font/local";
|
||||
import { ClerkProvider } from "@clerk/nextjs";
|
||||
import { Analytics } from "@vercel/analytics/react";
|
||||
|
||||
import { cn } from "@acme/ui";
|
||||
import { Toaster } from "@acme/ui/toaster";
|
||||
|
||||
import { TailwindIndicator } from "~/components/tailwind-indicator";
|
||||
import { ThemeProvider } from "~/components/theme-provider";
|
||||
import { siteConfig } from "./config";
|
||||
|
||||
const fontSans = Inter({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-sans",
|
||||
});
|
||||
const fontCal = LocalFont({
|
||||
src: "../styles/calsans.ttf",
|
||||
variable: "--font-cal",
|
||||
});
|
||||
|
||||
export const metadata = {
|
||||
title: {
|
||||
default: siteConfig.name,
|
||||
template: `%s - ${siteConfig.name}`,
|
||||
},
|
||||
description: siteConfig.description,
|
||||
icons: {
|
||||
icon: "/favicon.ico",
|
||||
},
|
||||
openGraph: {
|
||||
images: [{ url: "/opengraph-image.png" }],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: siteConfig.name,
|
||||
description: siteConfig.description,
|
||||
images: [{ url: "https://acme-corp-lib.vercel.app/opengraph-image.png" }],
|
||||
creator: "@jullerino",
|
||||
},
|
||||
metadataBase: new URL("https://acme-corp.jumr.dev"),
|
||||
};
|
||||
|
||||
export default function RootLayout(props: { children: React.ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<ClerkProvider>
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body
|
||||
className={cn(
|
||||
"min-h-screen font-sans antialiased",
|
||||
fontSans.variable,
|
||||
fontCal.variable,
|
||||
)}
|
||||
>
|
||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||
{props.children}
|
||||
<TailwindIndicator />
|
||||
</ThemeProvider>
|
||||
<Analytics />
|
||||
<Toaster />
|
||||
</body>
|
||||
</html>
|
||||
</ClerkProvider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
import dynamic from "next/dynamic";
|
||||
import Link from "next/link";
|
||||
|
||||
import { cn } from "@acme/ui";
|
||||
import { Button } from "@acme/ui/button";
|
||||
import * as Icons from "@acme/ui/icons";
|
||||
|
||||
import { siteConfig } from "~/app/config";
|
||||
|
||||
const ThemeToggle = dynamic(() => import("~/components/theme-toggle"), {
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="gap-1 px-2 text-lg font-semibold md:text-base"
|
||||
>
|
||||
<div className="h-6 w-6 animate-pulse rounded-full bg-muted-foreground/70" />
|
||||
<span className="w-14 animate-pulse rounded bg-muted-foreground/70 capitalize">
|
||||
|
||||
</span>
|
||||
</Button>
|
||||
),
|
||||
});
|
||||
|
||||
export function SiteFooter(props: { className?: string }) {
|
||||
return (
|
||||
<footer className={cn("container border-t", props.className)}>
|
||||
<div className="my-4 grid grid-cols-2 md:flex md:items-center">
|
||||
<Link
|
||||
href="/"
|
||||
className="col-start-1 row-start-1 flex items-center gap-2 md:mr-2"
|
||||
>
|
||||
<Icons.Logo className="h-6 w-6" />
|
||||
<p className="text-lg font-medium md:hidden">{siteConfig.name}</p>
|
||||
</Link>
|
||||
<p className="col-span-full row-start-2 text-center text-sm leading-loose text-muted-foreground md:flex-1 md:text-left">
|
||||
Built by{" "}
|
||||
<a
|
||||
href={siteConfig.twitter}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
Julius
|
||||
</a>
|
||||
. Inspired by{" "}
|
||||
<a
|
||||
href="https://tx.shadcn.com"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
Taxonomy
|
||||
</a>
|
||||
. Components by{" "}
|
||||
<a
|
||||
href="https://twitter.com/shadcn"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
Shadcn
|
||||
</a>
|
||||
. The source code is available on{" "}
|
||||
<a
|
||||
href={siteConfig.github}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
<div className="col-start-2 row-start-1 flex h-12 justify-end">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { Button } from "@acme/ui/button";
|
||||
import * as Icons from "@acme/ui/icons";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@acme/ui/popover";
|
||||
import { ScrollArea } from "@acme/ui/scroll-area";
|
||||
|
||||
import { Search } from "~/app/(dashboard)/_components/search";
|
||||
import { navItems, siteConfig } from "~/app/config";
|
||||
import ThemeToggle from "./theme-toggle";
|
||||
|
||||
export function MobileDropdown() {
|
||||
const [isOpen, setIsOpen] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.body.classList.add("overflow-hidden");
|
||||
} else {
|
||||
document.body.classList.remove("overflow-hidden");
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="mr-2 px-0 hover:bg-transparent focus-visible:bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0 md:hidden"
|
||||
>
|
||||
<Icons.Logo className="mr-2 h-6 w-6" />
|
||||
<span className="text-lg font-bold tracking-tight">
|
||||
{siteConfig.name}
|
||||
</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="z-40 mt-2 h-[calc(100vh-4rem)] w-screen animate-none rounded-none border-none transition-transform">
|
||||
<Search />
|
||||
<ScrollArea className="py-8">
|
||||
{navItems.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
// className="mt-2 flex items-center text-lg font-semibold sm:text-sm"
|
||||
className="flex py-1 text-base font-medium text-muted-foreground transition-colors hover:text-primary"
|
||||
>
|
||||
{item.title}
|
||||
</Link>
|
||||
))}
|
||||
</ScrollArea>
|
||||
<div className="border-t pt-4">
|
||||
<ThemeToggle side="top" align="start" />
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
export function TailwindIndicator() {
|
||||
if (process.env.NODE_ENV === "production") return null;
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-1 left-1 z-50 flex h-6 w-6 items-center justify-center rounded-full bg-gray-800 p-3 font-mono text-xs text-white">
|
||||
<div className="block sm:hidden">xs</div>
|
||||
<div className="hidden sm:block md:hidden lg:hidden xl:hidden 2xl:hidden">
|
||||
sm
|
||||
</div>
|
||||
<div className="hidden md:block lg:hidden xl:hidden 2xl:hidden">md</div>
|
||||
<div className="hidden lg:block xl:hidden 2xl:hidden">lg</div>
|
||||
<div className="hidden xl:block 2xl:hidden">xl</div>
|
||||
<div className="hidden 2xl:block">2xl</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { ThemeProvider as NextThemeProvider } from "next-themes";
|
||||
|
||||
export const ThemeProvider = NextThemeProvider;
|
||||
@@ -1,56 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { useTheme } from "next-themes";
|
||||
|
||||
import { Button } from "@acme/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@acme/ui/dropdown-menu";
|
||||
import * as Icons from "@acme/ui/icons";
|
||||
|
||||
export default function ThemeToggle(props: {
|
||||
align?: "center" | "start" | "end";
|
||||
side?: "top" | "bottom";
|
||||
}) {
|
||||
const { setTheme, theme } = useTheme();
|
||||
|
||||
const triggerIcon = {
|
||||
light: <Icons.Sun className="h-6 w-6" />,
|
||||
dark: <Icons.Moon className="h-6 w-6" />,
|
||||
system: <Icons.System className="h-6 w-6" />,
|
||||
}[theme as "light" | "dark" | "system"];
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="gap-1 px-2 text-lg font-semibold md:text-base"
|
||||
>
|
||||
{triggerIcon}
|
||||
<span className="capitalize">{theme}</span>
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align={props.align} side={props.side}>
|
||||
<DropdownMenuItem onClick={() => setTheme("light")}>
|
||||
<Icons.Sun className="mr-2 h-4 w-4" />
|
||||
<span>Light</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme("dark")}>
|
||||
<Icons.Moon className="mr-2 h-4 w-4" />
|
||||
<span>Dark</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme("system")}>
|
||||
<Icons.System className="mr-2 h-4 w-4" />
|
||||
<span>System</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
import Link from "next/link";
|
||||
import { currentUser } from "@clerk/nextjs";
|
||||
import {
|
||||
CreditCard,
|
||||
LogIn,
|
||||
LogOut,
|
||||
PlusCircle,
|
||||
Settings,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@acme/ui/avatar";
|
||||
import { Button } from "@acme/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuTrigger,
|
||||
} from "@acme/ui/dropdown-menu";
|
||||
|
||||
export async function UserNav() {
|
||||
const user = await currentUser();
|
||||
// if (!user) redirect("/signin");
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<Link href="/signin">
|
||||
<Button variant="ghost" className="relative h-8 w-8 rounded">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarFallback className="bg-transparent">
|
||||
<LogIn className="h-6 w-6" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</Button>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
const fullname = `${user.firstName} ${user.lastName}`;
|
||||
const initials = fullname
|
||||
.split(" ")
|
||||
.map((n) => n[0])
|
||||
.join("");
|
||||
const email = user.emailAddresses.find(
|
||||
(e) => e.id === user.primaryEmailAddressId,
|
||||
)?.emailAddress;
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="relative h-8 w-8 rounded-full">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarImage src={user.imageUrl} alt={user.username ?? ""} />
|
||||
<AvatarFallback>{initials}</AvatarFallback>
|
||||
</Avatar>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-56" align="end" forceMount>
|
||||
<DropdownMenuLabel className="font-normal">
|
||||
<div className="flex flex-col space-y-1">
|
||||
<p className="text-sm font-medium leading-none">
|
||||
{user.firstName} {user.lastName}
|
||||
</p>
|
||||
<p className="text-xs leading-none text-muted-foreground">
|
||||
{email}
|
||||
</p>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/${user.id}/settings`}>
|
||||
<User className="mr-2 h-4 w-4" />
|
||||
<span>Profile</span>
|
||||
<DropdownMenuShortcut>⇧⌘P</DropdownMenuShortcut>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/${user.id}/billing`}>
|
||||
<CreditCard className="mr-2 h-4 w-4" />
|
||||
<span>Billing</span>
|
||||
<DropdownMenuShortcut>⌘B</DropdownMenuShortcut>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem disabled>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
<span>Settings</span>
|
||||
<DropdownMenuShortcut>⌘S</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem disabled>
|
||||
<PlusCircle className="mr-2 h-4 w-4" />
|
||||
<span>New Team</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/signout">
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
<span>Log out</span>
|
||||
<DropdownMenuShortcut>⇧⌘Q</DropdownMenuShortcut>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import { createEnv } from "@t3-oss/env-nextjs";
|
||||
import { z } from "zod";
|
||||
|
||||
export const env = createEnv({
|
||||
shared: {
|
||||
NODE_ENV: z.enum(["development", "test", "production"]),
|
||||
},
|
||||
server: {
|
||||
DATABASE_URL: z.string().url(),
|
||||
CLERK_SECRET_KEY: z.string().optional(),
|
||||
STRIPE_WEBHOOK_SECRET: z.string(),
|
||||
},
|
||||
client: {
|
||||
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: z.string().min(1),
|
||||
},
|
||||
// Client side variables gets destructured here due to Next.js static analysis
|
||||
// Shared ones are also included here for good measure since the behavior has been inconsistent
|
||||
experimental__runtimeEnv: {
|
||||
NODE_ENV: process.env.NODE_ENV,
|
||||
|
||||
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY:
|
||||
process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY,
|
||||
},
|
||||
skipValidation:
|
||||
!!process.env.SKIP_ENV_VALIDATION ||
|
||||
process.env.npm_lifecycle_event === "lint",
|
||||
});
|
||||
@@ -1,6 +0,0 @@
|
||||
export const currencySymbol = (curr: string) =>
|
||||
({
|
||||
USD: "$",
|
||||
EUR: "€",
|
||||
GBP: "£",
|
||||
})[curr] ?? curr;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user