This commit is contained in:
Turbobot
2024-03-21 13:41:16 +08:00
committed by giancarlo
commit bb58169fe9
204 changed files with 26228 additions and 0 deletions

55
packages/api/package.json Normal file
View File

@@ -0,0 +1,55 @@
{
"name": "@acme/api",
"private": true,
"version": "0.1.0",
"exports": {
".": "./src/index.ts",
"./env": "./src/env.mjs",
"./edge": "./src/edge.ts",
"./lambda": "./src/lambda.ts",
"./transformer": "./src/transformer.ts",
"./validators": "./src/validators.ts"
},
"typesVersions": {
"*": {
"*": [
"src/*"
]
}
},
"license": "MIT",
"scripts": {
"clean": "rm -rf .turbo node_modules",
"lint": "eslint .",
"format": "prettier --check \"**/*.{mjs,ts,json}\"",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@acme/db": "^0.1.0",
"@acme/stripe": "^0.1.0",
"@clerk/nextjs": "^4.29.4",
"@dinero.js/currencies": "2.0.0-alpha.14",
"@t3-oss/env-nextjs": "^0.7.3",
"@trpc/client": "next",
"@trpc/server": "next",
"dinero.js": "2.0.0-alpha.14",
"superjson": "2.2.1",
"zod": "^3.22.4",
"zod-form-data": "^2.0.2"
},
"devDependencies": {
"@acme/eslint-config": "^0.2.0",
"@acme/prettier-config": "^0.1.0",
"@acme/tsconfig": "^0.1.0",
"eslint": "^8.56.0",
"prettier": "^3.2.4",
"typescript": "^5.3.3"
},
"eslintConfig": {
"root": true,
"extends": [
"@acme/eslint-config/base"
]
},
"prettier": "@acme/prettier-config"
}

13
packages/api/src/edge.ts Normal file
View File

@@ -0,0 +1,13 @@
import { authRouter } from "./router/auth";
import { organizationsRouter } from "./router/organizations";
import { projectRouter } from "./router/project";
import { stripeRouter } from "./router/stripe";
import { createTRPCRouter } from "./trpc";
// Deployed to /trpc/edge/**
export const edgeRouter = createTRPCRouter({
project: projectRouter,
auth: authRouter,
stripe: stripeRouter,
organization: organizationsRouter,
});

19
packages/api/src/env.mjs Normal file
View File

@@ -0,0 +1,19 @@
import { createEnv } from "@t3-oss/env-nextjs";
import * as z from "zod";
export const env = createEnv({
shared: {},
server: {
NEXTJS_URL: z.preprocess(
(str) =>
process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : str,
process.env.VERCEL_URL ? z.string().min(1) : z.string().url(),
),
},
// 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: {},
skipValidation:
!!process.env.SKIP_ENV_VALIDATION ||
process.env.npm_lifecycle_event === "lint",
});

22
packages/api/src/index.ts Normal file
View File

@@ -0,0 +1,22 @@
import type { inferRouterInputs, inferRouterOutputs } from "@trpc/server";
import type { AppRouter } from "./root";
export { createTRPCContext, createInnerTRPCContext } from "./trpc";
// TODO: Maybe just export `createAction` instead of the whole `trpc` object?
export { t } from "./trpc";
export type { AppRouter } from "./root";
export { appRouter } from "./root";
/**
* Inference helpers for input types
* @example type HelloInput = RouterInputs['example']['hello']
**/
export type RouterInputs = inferRouterInputs<AppRouter>;
/**
* Inference helpers for output types
* @example type HelloOutput = RouterOutputs['example']['hello']
**/
export type RouterOutputs = inferRouterOutputs<AppRouter>;

View File

@@ -0,0 +1,7 @@
import { ingestionRouter } from "./router/ingestion";
import { createTRPCRouter } from "./trpc";
// Deployed to /trpc/lambda/**
export const lambdaRouter = createTRPCRouter({
ingestion: ingestionRouter,
});

8
packages/api/src/root.ts Normal file
View File

@@ -0,0 +1,8 @@
import { edgeRouter } from "./edge";
import { lambdaRouter } from "./lambda";
import { mergeRouters } from "./trpc";
// Used to provide a good DX with a single client
// Then, a custom link is used to generate the correct URL for the request
export const appRouter = mergeRouters(edgeRouter, lambdaRouter);
export type AppRouter = typeof appRouter;

View File

@@ -0,0 +1,28 @@
import { clerkClient } from "@clerk/nextjs";
import { createTRPCRouter, protectedProcedure } from "../trpc";
export const authRouter = createTRPCRouter({
mySubscription: protectedProcedure.query(async (opts) => {
const customer = await opts.ctx.db
.selectFrom("Customer")
.select(["plan", "endsAt"])
.where("clerkUserId", "=", opts.ctx.auth.userId)
.executeTakeFirst();
if (!customer) return null;
return { plan: customer.plan ?? null, endsAt: customer.endsAt ?? null };
}),
listOrganizations: protectedProcedure.query(async (opts) => {
const memberships = await clerkClient.users.getOrganizationMembershipList({
userId: opts.ctx.auth.userId,
});
return memberships.map(({ organization }) => ({
id: organization.id,
name: organization.name,
image: organization.imageUrl,
}));
}),
});

View File

@@ -0,0 +1,93 @@
import { z } from "zod";
import { zfd } from "zod-form-data";
import { genId } from "@acme/db";
import {
createTRPCRouter,
protectedApiFormDataProcedure,
protectedProcedure,
} from "../trpc";
globalThis.File = File;
const myFileValidator = z.preprocess(
// @ts-expect-error - this is a hack. not sure why it's needed since it should already be a File
(file: File) =>
new File([file], file.name, {
type: file.type,
lastModified: file.lastModified,
}),
zfd.file(z.instanceof(File)),
);
/**
* FIXME: Not all of these have to run on lambda, just the upload one
*/
export const ingestionRouter = createTRPCRouter({
byId: protectedProcedure
.input(z.object({ id: z.string() }))
.query(async (opts) => {
const ingestion = await opts.ctx.db
.selectFrom("Ingestion")
.select(["id", "createdAt", "hash", "schema", "origin", "parent"])
.where("id", "=", opts.input.id)
.executeTakeFirstOrThrow();
return ingestion;
}),
list: protectedProcedure
.input(
z.object({
projectId: z.string(),
limit: z.number().optional(),
}),
)
.query(async (opts) => {
let query = opts.ctx.db
.selectFrom("Ingestion")
.select(["id", "createdAt", "hash"])
.where("projectId", "=", opts.input.projectId);
if (opts.input.limit) {
query = query.limit(opts.input.limit).orderBy("createdAt", "desc");
}
const ingestions = await query.execute();
return ingestions.map((ingestion) => ({
...ingestion,
adds: Math.floor(Math.random() * 10),
subs: Math.floor(Math.random() * 10),
}));
}),
upload: protectedApiFormDataProcedure
.input(
zfd.formData({
hash: zfd.text(),
parent: zfd.text().optional(),
origin: zfd.text(),
schema: myFileValidator,
}),
)
.mutation(async (opts) => {
const fileContent = await opts.input.schema.text();
const id = "ingest_" + genId();
await opts.ctx.db
.insertInto("Ingestion")
.values({
id,
projectId: opts.ctx.apiKey.projectId,
hash: opts.input.hash,
parent: opts.input.parent,
origin: opts.input.origin,
schema: fileContent,
apiKeyId: opts.ctx.apiKey.id,
})
.executeTakeFirst();
return { status: "ok" };
}),
});

View File

@@ -0,0 +1,97 @@
import { clerkClient } from "@clerk/nextjs";
import { TRPCError } from "@trpc/server";
import * as z from "zod";
import {
createTRPCRouter,
protectedAdminProcedure,
protectedOrgProcedure,
} from "../trpc";
import { inviteOrgMemberSchema } from "../validators";
export const organizationsRouter = createTRPCRouter({
listMembers: protectedOrgProcedure.query(async (opts) => {
const { orgId } = opts.ctx.auth;
const members =
await clerkClient.organizations.getOrganizationMembershipList({
organizationId: orgId,
});
return members.map((member) => ({
id: member.id,
email: member.publicUserData?.identifier ?? "",
role: member.role,
joinedAt: member.createdAt,
avatarUrl: member.publicUserData?.imageUrl,
name: [
member.publicUserData?.firstName,
member.publicUserData?.lastName,
].join(" "),
}));
}),
deleteMember: protectedAdminProcedure
.input(z.object({ userId: z.string() }))
.mutation(async (opts) => {
const { orgId } = opts.ctx.auth;
try {
const member =
await clerkClient.organizations.deleteOrganizationMembership({
organizationId: orgId,
userId: opts.input.userId,
});
return { memberName: member.publicUserData?.firstName };
} catch (e) {
console.log("Error deleting member", e);
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
}),
inviteMember: protectedAdminProcedure
.input(inviteOrgMemberSchema)
.mutation(async (opts) => {
const { orgId } = opts.ctx.auth;
const { email } = opts.input;
const users = await clerkClient.users.getUserList({
emailAddress: [email],
});
const user = users[0];
if (users.length === 0 || !user) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
if (users.length > 1) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Multiple users found with that email address",
});
}
const member =
await clerkClient.organizations.createOrganizationMembership({
organizationId: orgId,
userId: user.id,
role: opts.input.role,
});
const { firstName, lastName } = member.publicUserData ?? {};
return { name: [firstName, lastName].join(" ") };
}),
deleteOrganization: protectedAdminProcedure.mutation(async (opts) => {
const { orgId } = opts.ctx.auth;
await clerkClient.organizations.deleteOrganization(orgId);
}),
});

View File

@@ -0,0 +1,414 @@
import { clerkClient } from "@clerk/nextjs";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { genId } from "@acme/db";
import {
createTRPCRouter,
protectedAdminProcedure,
protectedProcedure,
} from "../trpc";
import {
createApiKeySchema,
createProjectSchema,
renameProjectSchema,
transferToOrgSchema,
} from "../validators";
const PROJECT_LIMITS = {
FREE: 1,
PRO: 3,
} as const;
export const projectRouter = createTRPCRouter({
create: protectedProcedure
.input(createProjectSchema)
.mutation(async (opts) => {
const { userId, orgId } = opts.ctx.auth;
const { name } = opts.input;
// Check if limit is reached
let query = opts.ctx.db
.selectFrom("Project")
.select(({ fn }) => [fn.count<number>("id").as("projects")]);
if (orgId) {
query = query.where("organizationId", "=", orgId);
} else {
query = query.where("userId", "=", userId);
}
const projects = (await query.executeTakeFirst())?.projects ?? 0;
// FIXME: Don't hardcode the limit to PRO
if (projects >= PROJECT_LIMITS.PRO) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Limit reached" });
}
const projectId = "project_" + genId();
await opts.ctx.db
.insertInto("Project")
.values({
id: projectId,
name,
userId: orgId ? null : userId,
organizationId: orgId,
})
.execute();
return projectId;
}),
rename: protectedProcedure
.input(renameProjectSchema)
.mutation(async (opts) => {
const { projectId, name } = opts.input;
// TODO: Validate permissions, should anyone with access to the project be able to change the name?
await opts.ctx.db
.updateTable("Project")
.set({
name,
})
.where("id", "=", projectId)
.execute();
}),
delete: protectedProcedure
.input(z.object({ id: z.string() }))
.mutation(async (opts) => {
const { userId, orgId } = opts.ctx.auth;
const deleteQuery = opts.ctx.db
.deleteFrom("Project")
.where("id", "=", opts.input.id);
// TODO: Check billing etc
if (orgId) {
// TODO: Check permissions
await deleteQuery.where("organizationId", "=", orgId).execute();
} else {
await deleteQuery.where("userId", "=", userId).execute();
}
}),
transferToPersonal: protectedAdminProcedure
.input(z.object({ id: z.string() }))
.mutation(async (opts) => {
const project = await opts.ctx.db
.selectFrom("Project")
.select(["id", "userId", "organizationId"])
.where("id", "=", opts.input.id)
.executeTakeFirst();
if (!project) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Project not found",
});
}
if (!project.organizationId) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Project is already personal",
});
}
await opts.ctx.db
.updateTable("Project")
.set({
userId: opts.ctx.auth.userId,
organizationId: null,
})
.where("id", "=", project.id)
.execute();
}),
transferToOrganization: protectedProcedure
.input(transferToOrgSchema)
.mutation(async (opts) => {
const { userId, orgId: userOrgId, orgRole } = opts.ctx.auth;
const { orgId: targetOrgId } = opts.input;
const orgs = await clerkClient.users.getOrganizationMembershipList({
userId: userId,
});
const org = orgs.find((org) => org.organization.id === targetOrgId);
if (!org) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You're not a member of the target organization",
});
}
const project = await opts.ctx.db
.selectFrom("Project")
.select(["id", "userId", "organizationId"])
.where(({ eb, and, or }) =>
and([
eb("id", "=", opts.input.projectId),
or([
eb("userId", "=", userId),
eb("organizationId", "=", userOrgId ?? ""),
]),
]),
)
.executeTakeFirst();
if (!project) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Project not found",
});
}
if (project.organizationId === targetOrgId) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Project is already in the target organization",
});
}
if (
project.organizationId &&
project.organizationId !== userOrgId &&
orgRole !== "admin"
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You must be an admin to transfer this project",
});
}
await opts.ctx.db
.updateTable("Project")
.set({
userId: null,
organizationId: targetOrgId,
})
.where("id", "=", project.id)
.execute();
}),
listByActiveWorkspace: protectedProcedure.query(async (opts) => {
const { userId, orgId } = opts.ctx.auth;
let query = opts.ctx.db
.selectFrom("Project")
.select(["id", "name", "url", "tier"]);
if (orgId) {
query = query.where("organizationId", "=", orgId);
} else {
query = query.where("userId", "=", userId);
}
const projects = await query.execute();
// FIXME: Don't hardcode the limit to PRO
return {
projects,
limit: PROJECT_LIMITS.PRO,
limitReached: projects.length >= PROJECT_LIMITS.PRO,
};
}),
byId: protectedProcedure
.input(z.object({ id: z.string() }))
.query(async (opts) => {
const { userId } = opts.ctx.auth;
const { id } = opts.input;
const orgs = await clerkClient.users.getOrganizationMembershipList({
userId: userId,
});
const orgIds = orgs.map((org) => org.organization.id);
// Verify the user has access to the project
const query = opts.ctx.db
.selectFrom("Project")
.select(["id", "name", "url", "tier", "organizationId"])
.where(({ eb, and, or }) =>
and([
eb("id", "=", id),
orgIds.length > 0
? or([
eb("userId", "=", userId),
eb("organizationId", "in", orgIds),
])
: eb("userId", "=", userId),
]),
);
const project = await query.executeTakeFirst();
if (!project) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Project not found",
});
}
return project;
}),
listApiKeys: protectedProcedure
.input(
z.object({
projectId: z.string(),
}),
)
.query(async (opts) => {
const { userId } = opts.ctx.auth;
const { projectId } = opts.input;
const apiKeys = await opts.ctx.db
.selectFrom("ApiKey")
.select([
"id",
"name",
"key",
"createdAt",
"lastUsed",
"expiresAt",
"revokedAt",
])
.where("projectId", "=", projectId)
.where("clerkUserId", "=", userId)
// first active, then expired, then revoked
.orderBy((eb) =>
eb
.case()
.when("revokedAt", "is not", null)
.then(3)
.when(
eb.and([
eb("expiresAt", "is not", null),
eb("expiresAt", "<", new Date()),
]),
)
.then(2)
.else(1)
.end(),
)
.orderBy("createdAt", "desc")
.execute();
// TODO: Project admins should maybe be able to see all keys for the project?
return apiKeys;
}),
createApiKey: protectedProcedure
.input(createApiKeySchema)
.mutation(async (opts) => {
const projectId = opts.input.projectId;
const userId = opts.ctx.auth.userId;
// Verify the user has access to the project
const project = await opts.ctx.db
.selectFrom("Project")
.select(["id", "name", "userId", "organizationId"])
.where("id", "=", projectId)
.executeTakeFirst();
if (!project) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Project not found",
});
}
if (project.userId && project.userId !== userId) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You don't have access to this project",
});
}
if (project.organizationId) {
const orgs = await clerkClient.users.getOrganizationMembershipList({
userId,
});
const isMemberInProjectOrg = orgs.some(
(org) => org.organization.id === project.organizationId,
);
if (!isMemberInProjectOrg) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You don't have access to this project",
});
}
}
// Generate the key
const apiKey = "sk_live_" + genId();
const apiKeyId = "api_key_" + genId();
await opts.ctx.db
.insertInto("ApiKey")
.values({
id: apiKeyId,
name: opts.input.name,
key: apiKey,
expiresAt: opts.input.expiresAt,
projectId: opts.input.projectId,
clerkUserId: userId,
})
.execute();
return apiKey;
}),
revokeApiKeys: protectedProcedure
.input(z.object({ ids: z.string().array() }))
.mutation(async (opts) => {
const { userId } = opts.ctx.auth;
const result = await opts.ctx.db
.updateTable("ApiKey")
.set({ revokedAt: new Date() })
.where("id", "in", opts.input.ids)
.where("clerkUserId", "=", String(userId))
.where("revokedAt", "is", null)
.executeTakeFirst();
if (result.numUpdatedRows === BigInt(0)) {
throw new TRPCError({
code: "NOT_FOUND",
message: "API key not found",
});
}
return { success: true, numRevoked: result.numUpdatedRows };
}),
rollApiKey: protectedProcedure
.input(z.object({ id: z.string() }))
.mutation(async (opts) => {
const apiKey = await opts.ctx.db
.selectFrom("ApiKey")
.select(["id"])
.where("id", "=", opts.input.id)
.where("clerkUserId", "=", opts.ctx.auth.userId)
.executeTakeFirst();
if (!apiKey) {
throw new TRPCError({
code: "NOT_FOUND",
message: "API key not found",
});
}
const newKey = "sk_live_" + genId();
await opts.ctx.db
.updateTable("ApiKey")
.set({ key: newKey })
.where("id", "=", opts.input.id)
.execute();
return newKey;
}),
});

View File

@@ -0,0 +1,111 @@
import { currentUser } from "@clerk/nextjs";
import * as currencies from "@dinero.js/currencies";
import { dinero } from "dinero.js";
import * as z from "zod";
import { PLANS, stripe } from "@acme/stripe";
import { env } from "../env.mjs";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
import { purchaseOrgSchema } from "../validators";
export const stripeRouter = createTRPCRouter({
createSession: protectedProcedure
.input(z.object({ planId: z.string() }))
.mutation(async (opts) => {
const { userId } = opts.ctx.auth;
const customer = await opts.ctx.db
.selectFrom("Customer")
.select(["id", "plan", "stripeId"])
.where("clerkUserId", "=", userId)
.executeTakeFirst();
const returnUrl = env.NEXTJS_URL + "/dashboard";
if (customer && customer.plan !== "FREE") {
/**
* User is subscribed, create a billing portal session
*/
const session = await stripe.billingPortal.sessions.create({
customer: customer.stripeId,
return_url: returnUrl,
});
return { success: true as const, url: session.url };
}
/**
* User is not subscribed, create a checkout session
* Use existing email address if available
*/
const user = await currentUser();
const email = user?.emailAddresses.find(
(addr) => addr.id === user?.primaryEmailAddressId,
)?.emailAddress;
const session = await stripe.checkout.sessions.create({
mode: "subscription",
payment_method_types: ["card"],
customer_email: email,
client_reference_id: userId,
subscription_data: { metadata: { userId } },
cancel_url: returnUrl,
success_url: returnUrl,
line_items: [{ price: PLANS.PRO?.priceId, quantity: 1 }],
});
if (!session.url) return { success: false as const };
return { success: true as const, url: session.url };
}),
plans: publicProcedure.query(async () => {
const proPrice = await stripe.prices.retrieve(PLANS.PRO.priceId);
const stdPrice = await stripe.prices.retrieve(PLANS.STANDARD.priceId);
return [
{
...PLANS.STANDARD,
price: dinero({
amount: stdPrice.unit_amount!,
currency:
currencies[stdPrice.currency as keyof typeof currencies] ??
currencies.USD,
}),
},
{
...PLANS.PRO,
price: dinero({
amount: proPrice.unit_amount!,
currency:
currencies[proPrice.currency as keyof typeof currencies] ??
currencies.USD,
}),
},
];
}),
purchaseOrg: protectedProcedure
.input(purchaseOrgSchema)
.mutation(async (opts) => {
const { userId } = opts.ctx.auth;
const { orgName, planId } = opts.input;
const baseUrl = new URL(opts.ctx.req?.nextUrl ?? env.NEXTJS_URL).origin;
const session = await stripe.checkout.sessions.create({
mode: "subscription",
payment_method_types: ["card"],
client_reference_id: userId,
subscription_data: {
metadata: { userId, organizationName: orgName },
},
success_url: baseUrl + "/onboarding",
cancel_url: baseUrl,
line_items: [{ price: planId, quantity: 1 }],
});
if (!session.url) return { success: false as const };
return { success: true as const, url: session.url };
}),
});

View File

@@ -0,0 +1,30 @@
import { dinero } from "dinero.js";
import type { Dinero, DineroSnapshot } from "dinero.js";
import superjson from "superjson";
import type { JSONValue } from "superjson/dist/types";
/**
* TODO: Maybe put this in a shared package that can be safely shared between `api`, `nextjs` and `expo` packages
*/
superjson.registerCustom(
{
isApplicable: (val): val is Dinero<number> => {
try {
// if this doesn't crash we're kinda sure it's a Dinero instance
(val as Dinero<number>).calculator.add(1, 2);
return true;
} catch {
return false;
}
},
serialize: (val) => {
return val.toJSON() as JSONValue;
},
deserialize: (val) => {
return dinero(val as DineroSnapshot<number>);
},
},
"Dinero",
);
export const transformer = superjson;

224
packages/api/src/trpc.ts Normal file
View File

@@ -0,0 +1,224 @@
/**
* YOU PROBABLY DON'T NEED TO EDIT THIS FILE, UNLESS:
* 1. You want to modify request context (see Part 1)
* 2. You want to create a new middleware or type of procedure (see Part 3)
*
* tl;dr - this is where all the tRPC server stuff is created and plugged in.
* The pieces you will need to use are documented accordingly near the end
*/
import type { NextRequest } from "next/server";
import type {
SignedInAuthObject,
SignedOutAuthObject,
} from "@clerk/nextjs/server";
import { initTRPC, TRPCError } from "@trpc/server";
import { ZodError } from "zod";
import { db } from "@acme/db";
import { transformer } from "./transformer";
type AuthContext = SignedInAuthObject | SignedOutAuthObject;
/**
* 1. CONTEXT
*
* This section defines the "contexts" that are available in the backend API
*
* These allow you to access things like the database, the session, etc, when
* processing a request
*
*/
interface CreateContextOptions {
headers: Headers;
auth: AuthContext;
apiKey?: string | null;
req?: NextRequest;
}
/**
* This helper generates the "internals" for a tRPC context. If you need to use
* it, you can export it from here
*
* Examples of things you may need it for:
* - testing, so we dont have to mock Next.js' req/res
* - trpc's `createSSGHelpers` where we don't have req/res
* @see https://create.t3.gg/en/usage/trpc#-servertrpccontextts
*/
export const createInnerTRPCContext = (opts: CreateContextOptions) => {
return {
...opts,
db,
};
};
/**
* This is the actual context you'll use in your router. It will be used to
* process every request that goes through your tRPC endpoint
* @link https://trpc.io/docs/context
*/
export const createTRPCContext = async (opts: {
headers: Headers;
auth: AuthContext;
req?: NextRequest;
// eslint-disable-next-line @typescript-eslint/require-await
}) => {
const apiKey = opts.req?.headers.get("x-acme-api-key");
return createInnerTRPCContext({
auth: opts.auth,
apiKey,
req: opts.req,
headers: opts.headers,
});
};
/**
* 2. INITIALIZATION
*
* This is where the trpc api is initialized, connecting the context and
* transformer
*/
export const t = initTRPC.context<typeof createTRPCContext>().create({
transformer,
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
zodError:
error.cause instanceof ZodError ? error.cause.flatten() : null,
},
};
},
});
/**
* 3. ROUTER & PROCEDURE (THE IMPORTANT BIT)
*
* These are the pieces you use to build your tRPC API. You should import these
* a lot in the /src/server/api/routers folder
*/
/**
* This is how you create new routers and subrouters in your tRPC API
* @see https://trpc.io/docs/router
*/
export const createTRPCRouter = t.router;
export const mergeRouters = t.mergeRouters;
/**
* Public (unauthed) procedure
*
* This is the base piece you use to build new queries and mutations on your
* tRPC API. It does not guarantee that a user querying is authorized, but you
* can still access user session data if they are logged in
*/
export const publicProcedure = t.procedure;
/**
* Reusable procedure that enforces users are logged in before running the
* code
*/
export const protectedProcedure = t.procedure.use(({ ctx, next }) => {
if (!ctx.auth?.userId) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
return next({
ctx: {
auth: {
...ctx.auth,
userId: ctx.auth.userId,
},
},
});
});
/**
* Reusable procedure that enforces users are part of an organization before
* running the code
*/
export const protectedOrgProcedure = protectedProcedure.use(({ ctx, next }) => {
if (!ctx.auth?.orgId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You must be in an organization to perform this action",
});
}
return next({
ctx: {
auth: {
...ctx.auth,
orgId: ctx.auth.orgId,
},
},
});
});
/**
* Procedure that enforces users are admins of an organization before running
* the code
*/
export const protectedAdminProcedure = protectedOrgProcedure.use(
({ ctx, next }) => {
if (ctx.auth.orgRole !== "admin") {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You must be an admin to perform this action",
});
}
return next({
ctx: {
auth: {
...ctx.auth,
orgRole: ctx.auth.orgRole,
},
},
});
},
);
/**
* Procedure to authenticate API requests with an API key
*/
export const protectedApiProcedure = t.procedure.use(async ({ ctx, next }) => {
if (!ctx.apiKey) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
// Check db for API key
const apiKey = await ctx.db
.selectFrom("ApiKey")
.select(["id", "key", "projectId"])
.where("ApiKey.key", "=", ctx.apiKey)
.where("revokedAt", "is", null)
.executeTakeFirst();
if (!apiKey) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
void ctx.db
.updateTable("ApiKey")
.set({ lastUsed: new Date() })
.where("id", "=", apiKey.id)
.execute();
return next({
ctx: {
apiKey,
},
});
});
/**
* Procedure to parse form data and put it in the rawInput and authenticate requests with an API key
*/
export const protectedApiFormDataProcedure = protectedApiProcedure.use(
async function formData(opts) {
const formData = await opts.ctx.req?.formData?.();
if (!formData) throw new TRPCError({ code: "BAD_REQUEST" });
return opts.next({
input: formData,
});
},
);

View File

@@ -0,0 +1,55 @@
import * as z from "zod";
import { PLANS } from "@acme/stripe/plans";
/**
* Shared validators used in both the frontend and backend
*/
export const createProjectSchema = z.object({
name: z.string().min(5, "Name must be at least 5 characters"),
url: z.string().url("Must be a valid URL").optional(),
});
export type CreateProject = z.infer<typeof createProjectSchema>;
export const renameProjectSchema = z.object({
projectId: z.string(),
name: z.string().min(5, "Name must be at least 5 characters"),
});
export type RenameProject = z.infer<typeof renameProjectSchema>;
export const purchaseOrgSchema = z.object({
orgName: z.string().min(5, "Name must be at least 5 characters"),
planId: z.string().refine(
(str) =>
Object.values(PLANS)
.map((p) => p.priceId)
.includes(str),
"Invalid planId",
),
});
export type PurchaseOrg = z.infer<typeof purchaseOrgSchema>;
export const createApiKeySchema = z.object({
projectId: z.string(),
name: z.string(),
expiresAt: z.date().optional(),
});
export type CreateApiKey = z.infer<typeof createApiKeySchema>;
export const MEMBERSHIP = {
Member: "basic_member",
Admin: "admin",
} as const;
export const inviteOrgMemberSchema = z.object({
email: z.string().email(),
role: z.nativeEnum(MEMBERSHIP),
});
export type InviteOrgMember = z.infer<typeof inviteOrgMemberSchema>;
export const transferToOrgSchema = z.object({
projectId: z.string(),
orgId: z.string(),
});
export type TransferToOrg = z.infer<typeof transferToOrgSchema>;

View File

@@ -0,0 +1,8 @@
{
"extends": "@acme/tsconfig/base.json",
"compilerOptions": {
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json",
},
"include": ["src"],
"exclude": ["node_modules"],
}

22
packages/db/index.ts Normal file
View File

@@ -0,0 +1,22 @@
// Generated by prisma/post-generate.ts
import { Kysely } from "kysely";
import { PlanetScaleDialect } from "kysely-planetscale";
import { customAlphabet } from "nanoid";
import type { DB } from "./prisma/types";
export { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/postgres";
export * from "./prisma/types";
export * from "./prisma/enums";
export const db = new Kysely<DB>({
dialect: new PlanetScaleDialect({
url: process.env.DATABASE_URL,
}),
});
// Use custom alphabet without special chars for less chaotic, copy-able URLs
// Will not collide for a long long time: https://zelark.github.io/nano-id-cc/
export const genId = customAlphabet("0123456789abcdefghijklmnopqrstuvwxyz", 16);

45
packages/db/package.json Normal file
View File

@@ -0,0 +1,45 @@
{
"name": "@acme/db",
"private": true,
"version": "0.1.0",
"exports": {
".": "./index.ts"
},
"license": "MIT",
"scripts": {
"clean": "rm -rf .turbo node_modules",
"db:generate": "pnpm with-env prisma generate",
"db:push": "pnpm with-env prisma db push --skip-generate",
"studio": "pnpm with-env prisma studio --port 5556",
"format": "prisma format && prettier --check \"**/*.{mjs,ts,json}\"",
"lint": "eslint .",
"typecheck": "tsc --noEmit",
"with-env": "dotenv -e ../../.env.local --"
},
"dependencies": {
"@planetscale/database": "^1.14.0",
"kysely": "^0.27.2",
"kysely-planetscale": "^1.4.0",
"nanoid": "^5.0.4"
},
"devDependencies": {
"@acme/eslint-config": "^0.2.0",
"@acme/prettier-config": "^0.1.0",
"@acme/tsconfig": "^0.1.0",
"dotenv-cli": "^7.3.0",
"eslint": "^8.56.0",
"prettier": "^3.2.4",
"prisma": "^5.8.1",
"prisma-kysely": "^1.7.1",
"typescript": "^5.3.3"
},
"eslintConfig": {
"extends": [
"@acme/eslint-config/base"
],
"rules": {
"@typescript-eslint/consistent-type-definitions": "off"
}
},
"prettier": "@acme/prettier-config"
}

View File

@@ -0,0 +1,12 @@
export const ProjectTier = {
FREE: "FREE",
PRO: "PRO",
} as const;
export type ProjectTier = (typeof ProjectTier)[keyof typeof ProjectTier];
export const SubscriptionPlan = {
FREE: "FREE",
STANDARD: "STANDARD",
PRO: "PRO",
} as const;
export type SubscriptionPlan =
(typeof SubscriptionPlan)[keyof typeof SubscriptionPlan];

View File

@@ -0,0 +1,83 @@
generator kysely {
provider = "prisma-kysely"
output = "."
enumFileName = "enums.ts"
}
datasource db {
provider = "mysql"
url = env("DATABASE_URL")
relationMode = "prisma"
}
enum ProjectTier {
FREE
PRO
}
model Project {
id String @id @db.VarChar(30) // prefix_ + nanoid (16)
createdAt DateTime @default(now())
// A project is tied to a Clerk User or Organization
organizationId String? @db.VarChar(36) // uuid v4
userId String? @db.VarChar(36) // uuid v4
name String
tier ProjectTier @default(FREE)
url String?
@@index([organizationId])
@@index([userId])
}
enum SubscriptionPlan {
FREE
STANDARD
PRO
}
model Customer {
id String @id @db.VarChar(30) // prefix_ + nanoid (16)
stripeId String @unique
subscriptionId String?
clerkUserId String
clerkOrganizationId String?
name String?
plan SubscriptionPlan?
paidUntil DateTime?
endsAt DateTime?
@@index([clerkUserId])
}
model ApiKey {
id String @id @db.VarChar(30) // prefix_ + nanoid (16)
createdAt DateTime @default(now())
expiresAt DateTime?
lastUsed DateTime?
revokedAt DateTime?
projectId String @db.VarChar(30) // prefix_ + nanoid (16)
clerkUserId String @db.VarChar(36) // uuid v4
name String @default("Secret Key")
key String @unique
@@index([projectId])
}
model Ingestion {
id String @id @db.VarChar(30) // prefix_ + nanoid (16)
createdAt DateTime @default(now())
projectId String @db.VarChar(30) // prefix_ + nanoid (16)
apiKeyId String @db.VarChar(30) // prefix_ + nanoid (16)
schema Json
hash String @db.VarChar(40) // sha1
parent String? @db.VarChar(40) // sha1
origin String @db.VarChar(100)
@@index([projectId])
}

View File

@@ -0,0 +1,57 @@
import type { ColumnType } from "kysely";
import type { ProjectTier, SubscriptionPlan } from "./enums";
export type Generated<T> =
T extends ColumnType<infer S, infer I, infer U>
? ColumnType<S, I | undefined, U>
: ColumnType<T, T | undefined, T>;
export type Timestamp = ColumnType<Date, Date | string, Date | string>;
export type ApiKey = {
id: string;
createdAt: Generated<Timestamp>;
expiresAt: Timestamp | null;
lastUsed: Timestamp | null;
revokedAt: Timestamp | null;
projectId: string;
clerkUserId: string;
name: Generated<string>;
key: string;
};
export type Customer = {
id: string;
stripeId: string;
subscriptionId: string | null;
clerkUserId: string;
clerkOrganizationId: string | null;
name: string | null;
plan: SubscriptionPlan | null;
paidUntil: Timestamp | null;
endsAt: Timestamp | null;
};
export type Ingestion = {
id: string;
createdAt: Generated<Timestamp>;
projectId: string;
apiKeyId: string;
schema: unknown;
hash: string;
parent: string | null;
origin: string;
};
export type Project = {
id: string;
createdAt: Generated<Timestamp>;
organizationId: string | null;
userId: string | null;
name: string;
tier: Generated<ProjectTier>;
url: string | null;
};
export type DB = {
ApiKey: ApiKey;
Customer: Customer;
Ingestion: Ingestion;
Project: Project;
};

View File

@@ -0,0 +1,8 @@
{
"extends": "@acme/tsconfig/base.json",
"compilerOptions": {
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json",
},
"include": ["*.ts", "prisma", "src"],
"exclude": ["node_modules"],
}

View File

@@ -0,0 +1,45 @@
{
"name": "@acme/stripe",
"private": true,
"version": "0.1.0",
"exports": {
".": "./src/index.ts",
"./plans": "./src/plans.ts",
"./env": "./src/env.mjs"
},
"typesVersions": {
"*": {
"*": [
"src/*"
]
}
},
"license": "MIT",
"scripts": {
"clean": "rm -rf .turbo node_modules",
"dev": "stripe listen --forward-to localhost:3000/api/webhooks/stripe",
"lint": "eslint .",
"format": "prettier --check \"**/*.{mjs,ts,json}\"",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@acme/db": "^0.1.0",
"@clerk/nextjs": "^4.29.4",
"@t3-oss/env-nextjs": "^0.7.3",
"stripe": "^14.13.0"
},
"devDependencies": {
"@acme/eslint-config": "^0.2.0",
"@acme/prettier-config": "^0.1.0",
"@acme/tsconfig": "^0.1.0",
"eslint": "^8.56.0",
"prettier": "^3.2.4",
"typescript": "^5.3.3"
},
"eslintConfig": {
"extends": [
"@acme/eslint-config/base"
]
},
"prettier": "@acme/prettier-config"
}

View File

@@ -0,0 +1,36 @@
import { createEnv } from "@t3-oss/env-nextjs";
import * as z from "zod";
export const env = createEnv({
shared: {},
server: {
NEXTJS_URL: z.preprocess(
(str) =>
process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : str,
process.env.VERCEL_URL ? z.string().min(1) : z.string().url(),
),
STRIPE_API_KEY: z.string(),
},
client: {
NEXT_PUBLIC_STRIPE_STD_PRODUCT_ID: z.string(),
NEXT_PUBLIC_STRIPE_STD_MONTHLY_PRICE_ID: z.string(),
NEXT_PUBLIC_STRIPE_PRO_PRODUCT_ID: z.string(),
NEXT_PUBLIC_STRIPE_PRO_MONTHLY_PRICE_ID: z.string(),
},
// 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: {
NEXT_PUBLIC_STRIPE_STD_PRODUCT_ID:
process.env.NEXT_PUBLIC_STRIPE_STD_PRODUCT_ID,
NEXT_PUBLIC_STRIPE_STD_MONTHLY_PRICE_ID:
process.env.NEXT_PUBLIC_STRIPE_STD_MONTHLY_PRICE_ID,
NEXT_PUBLIC_STRIPE_PRO_PRODUCT_ID:
process.env.NEXT_PUBLIC_STRIPE_PRO_PRODUCT_ID,
NEXT_PUBLIC_STRIPE_PRO_MONTHLY_PRICE_ID:
process.env.NEXT_PUBLIC_STRIPE_PRO_MONTHLY_PRICE_ID,
},
skipValidation:
!!process.env.SKIP_ENV_VALIDATION ||
process.env.npm_lifecycle_event === "lint",
});

View File

@@ -0,0 +1,13 @@
import { Stripe } from "stripe";
import { env } from "./env.mjs";
export * from "./plans";
export * from "./webhooks";
export type { Stripe };
export const stripe = new Stripe(env.STRIPE_API_KEY, {
apiVersion: "2023-10-16",
typescript: true,
});

View File

@@ -0,0 +1,41 @@
import { SubscriptionPlan } from "@acme/db";
import { env } from "./env.mjs";
interface PlanInfo {
key: SubscriptionPlan;
name: string;
description: string;
preFeatures?: string;
features: string[];
priceId: string;
}
export const PLANS: Record<SubscriptionPlan, PlanInfo> = {
STANDARD: {
key: SubscriptionPlan.STANDARD,
name: "Standard",
description: "For individuals",
features: ["Invite up to 1 team member", "Lorem ipsum dolor sit amet"],
priceId: env.NEXT_PUBLIC_STRIPE_STD_MONTHLY_PRICE_ID,
},
PRO: {
key: SubscriptionPlan.PRO,
name: "Pro",
description: "For teams",
preFeatures: "Everything in standard, plus",
features: ["Invite up to 5 team members", "Unlimited projects"],
priceId: env.NEXT_PUBLIC_STRIPE_PRO_MONTHLY_PRICE_ID,
},
FREE: {
key: SubscriptionPlan.FREE,
name: "Free",
description: "For individuals",
features: ["Invite up to 1 team member", "Lorem ipsum dolor sit amet"],
priceId: "no-id-necessary",
},
};
export function stripePriceToSubscriptionPlan(priceId: string | undefined) {
return Object.values(PLANS).find((plan) => plan.priceId === priceId);
}

View File

@@ -0,0 +1,153 @@
import { clerkClient } from "@clerk/nextjs";
import type Stripe from "stripe";
import { db, genId } from "@acme/db";
import { stripe } from ".";
import { stripePriceToSubscriptionPlan } from "./plans";
export async function handleEvent(event: Stripe.Event) {
switch (event.type) {
case "checkout.session.completed": {
const session = event.data.object;
if (typeof session.subscription !== "string") {
throw new Error("Missing or invalid subscription id");
}
const subscription = await stripe.subscriptions.retrieve(
session.subscription,
);
const customerId =
typeof subscription.customer === "string"
? subscription.customer
: subscription.customer.id;
const { userId, organizationName } = subscription.metadata;
if (!userId) {
throw new Error("Missing user id");
}
const customer = await db
.selectFrom("Customer")
.select("id")
.where("stripeId", "=", customerId)
.executeTakeFirst();
const subscriptionPlan = stripePriceToSubscriptionPlan(
subscription.items.data[0]?.price.id,
);
/**
* User is already subscribed, update their info
*/
if (customer) {
return await db
.updateTable("Customer")
.where("id", "=", customer.id)
.set({
stripeId: customerId,
subscriptionId: subscription.id,
paidUntil: new Date(subscription.current_period_end * 1000),
plan: subscriptionPlan?.key,
})
.execute();
}
/**
* User is not subscribed, create a new customer and org
*/
const organization = await clerkClient.organizations.createOrganization({
createdBy: userId,
name: organizationName!,
});
// TODO: SET ACTIVE ORG WHEN CLERK CAN BOTHER TO LET ME DO TAHT SERVERSIDE!!!
await db
.insertInto("Customer")
.values({
id: genId(),
clerkUserId: userId,
clerkOrganizationId: organization.id,
stripeId: customerId,
subscriptionId: subscription.id,
plan: subscriptionPlan?.key,
paidUntil: new Date(subscription.current_period_end * 1000),
endsAt: new Date(subscription.current_period_end * 1000),
})
.execute();
break;
}
case "invoice.payment_succeeded": {
const invoice = event.data.object;
if (typeof invoice.subscription !== "string") {
throw new Error("Missing or invalid subscription id");
}
const subscription = await stripe.subscriptions.retrieve(
invoice.subscription,
);
const subscriptionPlan = stripePriceToSubscriptionPlan(
subscription.items.data[0]?.price.id,
);
await db
.updateTable("Customer")
.where("subscriptionId", "=", subscription.id)
.set({
plan: subscriptionPlan?.key,
paidUntil: new Date(subscription.current_period_end * 1000),
})
.execute();
break;
}
case "invoice.payment_failed": {
// TODO: Handle failed payments
break;
}
case "customer.subscription.deleted": {
const subscription = event.data.object;
const customerId =
typeof subscription.customer === "string"
? subscription.customer
: subscription.customer.id;
await db
.updateTable("Customer")
.where("stripeId", "=", customerId)
.set({
subscriptionId: null,
plan: "FREE",
paidUntil: null,
})
.execute();
break;
}
case "customer.subscription.updated": {
const subscription = event.data.object;
const customerId =
typeof subscription.customer === "string"
? subscription.customer
: subscription.customer.id;
const subscriptionPlan = stripePriceToSubscriptionPlan(
subscription.items.data[0]?.price.id,
);
await db
.updateTable("Customer")
.where("stripeId", "=", customerId)
.set({
plan: subscriptionPlan?.key,
paidUntil: new Date(subscription.current_period_end * 1000),
})
.execute();
break;
}
default: {
console.log("🆗 Stripe Webhook Unhandled Event Type: ", event.type);
return;
}
}
console.log("✅ Stripe Webhook Processed");
}

View File

@@ -0,0 +1,8 @@
{
"extends": "@acme/tsconfig/base.json",
"compilerOptions": {
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json",
},
"include": ["*.ts", "src"],
"exclude": ["node_modules"],
}

97
packages/ui/package.json Normal file
View File

@@ -0,0 +1,97 @@
{
"name": "@acme/ui",
"private": true,
"version": "0.1.0",
"scripts": {
"clean": "git clean -xdf .turbo node_modules",
"format": "prettier --check \"**/*.{ts,tsx}\"",
"lint": "eslint .",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@radix-ui/react-avatar": "^1.0.4",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-scroll-area": "^1.0.5",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-toast": "^1.1.5",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"cmdk": "^0.2.0",
"lucide-react": "0.307.0",
"tailwind-merge": "^2.2.0",
"zod": "^3.22.4"
},
"peerDependencies": {
"@tanstack/react-table": "^8.10.7",
"react": "^18.2.0",
"react-day-picker": "^8.10.0",
"react-dom": "^18.2.0",
"react-hook-form": "^7.45.4",
"tailwindcss": "3.4.1",
"tailwindcss-animate": "^1.0.7"
},
"devDependencies": {
"@acme/eslint-config": "0.2.0",
"@acme/prettier-config": "0.1.0",
"@acme/tailwind-config": "0.1.0",
"@acme/tsconfig": "0.1.0",
"@tanstack/react-table": "^8.11.3",
"@types/react": "^18.2.48",
"@types/react-dom": "^18.2.18",
"date-fns": "^3.2.0",
"eslint": "^8.56.0",
"prettier": "^3.2.4",
"react": "18.2.0",
"react-day-picker": "^8.10.0",
"react-dom": "18.2.0",
"react-hook-form": "^7.49.2",
"tailwindcss": "3.4.1",
"tailwindcss-animate": "^1.0.7",
"typescript": "^5.3.3"
},
"eslintConfig": {
"root": true,
"extends": [
"@acme/eslint-config/base",
"@acme/eslint-config/react"
]
},
"prettier": "@acme/prettier-config",
"exports": {
".": "./src/index.ts",
"./avatar": "./src/avatar.tsx",
"./button": "./src/button.tsx",
"./calendar": "./src/calendar.tsx",
"./card": "./src/card.tsx",
"./checkbox": "./src/checkbox.tsx",
"./command": "./src/command.tsx",
"./data-table": "./src/data-table.tsx",
"./dialog": "./src/dialog.tsx",
"./dropdown-menu": "./src/dropdown-menu.tsx",
"./form": "./src/form.tsx",
"./icons": "./src/icons.tsx",
"./input": "./src/input.tsx",
"./label": "./src/label.tsx",
"./popover": "./src/popover.tsx",
"./scroll-area": "./src/scroll-area.tsx",
"./select": "./src/select.tsx",
"./sheet": "./src/sheet.tsx",
"./table": "./src/table.tsx",
"./tabs": "./src/tabs.tsx",
"./toaster": "./src/toaster.tsx",
"./use-toast": "./src/use-toast.tsx"
},
"typesVersions": {
"*": {
"*": [
"src/*"
]
}
}
}

View File

@@ -0,0 +1,50 @@
"use client";
import * as React from "react";
import * as AvatarPrimitive from "@radix-ui/react-avatar";
import { cn } from "./utils/cn";
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn(
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
className,
)}
{...props}
/>
));
Avatar.displayName = AvatarPrimitive.Root.displayName;
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square h-full w-full", className)}
{...props}
/>
));
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-muted",
className,
)}
{...props}
/>
));
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
export { Avatar, AvatarImage, AvatarFallback };

View File

@@ -0,0 +1,57 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva } from "class-variance-authority";
import type { VariantProps } from "class-variance-authority";
import { cn } from "./utils/cn";
const buttonVariants = cva(
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
},
);
Button.displayName = "Button";
export { Button, buttonVariants };

View File

@@ -0,0 +1,69 @@
"use client";
import * as React from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { DayPicker } from "react-day-picker";
import { buttonVariants } from "./button";
import { cn } from "./utils/cn";
export type { DateRange } from "react-day-picker";
export type CalendarProps = React.ComponentProps<typeof DayPicker>;
function Calendar({
className,
classNames,
showOutsideDays = true,
...props
}: CalendarProps) {
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn("p-3", className)}
classNames={{
months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
month: "space-y-4",
caption: "flex justify-center pt-1 relative items-center",
caption_label: "text-sm font-medium",
nav: "space-x-1 flex items-center",
nav_button: cn(
buttonVariants({ variant: "outline" }),
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100",
),
nav_button_previous: "absolute left-1",
nav_button_next: "absolute right-1",
table: "w-full border-collapse space-y-1",
head_row: "flex",
head_cell:
"text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]",
row: "flex w-full mt-2",
cell: "text-center text-sm p-0 relative [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20",
day: cn(
buttonVariants({ variant: "ghost" }),
"h-9 w-9 p-0 font-normal aria-selected:opacity-100",
),
day_selected:
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
day_today: "bg-accent text-accent-foreground",
day_outside: "text-muted-foreground opacity-50",
day_disabled: "text-muted-foreground opacity-50",
day_range_middle:
"aria-selected:bg-accent aria-selected:text-accent-foreground",
day_hidden: "invisible",
...classNames,
}}
components={{
IconLeft: ({ ...props }) => (
<ChevronLeft className="h-4 w-4" {...props} />
),
IconRight: ({ ...props }) => (
<ChevronRight className="h-4 w-4" {...props} />
),
}}
{...props}
/>
);
}
Calendar.displayName = "Calendar";
export { Calendar };

88
packages/ui/src/card.tsx Normal file
View File

@@ -0,0 +1,88 @@
import * as React from "react";
import { cn } from "./utils/cn";
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className,
)}
{...props}
/>
));
Card.displayName = "Card";
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
));
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className,
)}
{...props}
>
{props.children}
</h3>
));
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
));
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(" flex items-center p-6 pt-0", className)}
{...props}
/>
));
CardFooter.displayName = "CardFooter";
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardDescription,
CardContent,
};

View File

@@ -0,0 +1,30 @@
"use client";
import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { Check } from "lucide-react";
import { cn } from "./utils/cn";
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current")}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
));
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
export { Checkbox };

156
packages/ui/src/command.tsx Normal file
View File

@@ -0,0 +1,156 @@
"use client";
import * as React from "react";
import type { DialogProps } from "@radix-ui/react-dialog";
import { Command as CommandPrimitive } from "cmdk";
import { Search } from "lucide-react";
import { Dialog, DialogContent } from "./dialog";
import { cn } from "./utils/cn";
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
className,
)}
{...props}
/>
));
Command.displayName = CommandPrimitive.displayName;
type CommandDialogProps = DialogProps;
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0 shadow-2xl">
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
);
};
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
// eslint-disable-next-line react/no-unknown-property
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
"placeholder:text-foreground-muted flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
/>
</div>
));
CommandInput.displayName = CommandPrimitive.Input.displayName;
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
{...props}
/>
));
CommandList.displayName = CommandPrimitive.List.displayName;
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty
ref={ref}
className="py-6 text-center text-sm"
{...props}
/>
));
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
className,
)}
{...props}
/>
));
CommandGroup.displayName = CommandPrimitive.Group.displayName;
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
));
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
{...props}
/>
));
CommandItem.displayName = CommandPrimitive.Item.displayName;
const CommandShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className,
)}
{...props}
/>
);
};
CommandShortcut.displayName = "CommandShortcut";
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
};

View File

@@ -0,0 +1,80 @@
"use client";
import type { ColumnDef } from "@tanstack/react-table";
import {
flexRender,
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "./table";
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
}
export function DataTable<TData, TValue>({
columns,
data,
}: DataTableProps<TData, TValue>) {
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
});
return (
<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>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
>
{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>
);
}

122
packages/ui/src/dialog.tsx Normal file
View File

@@ -0,0 +1,122 @@
"use client";
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { cn } from "./utils/cn";
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className,
)}
{...props}
/>
);
DialogHeader.displayName = "DialogHeader";
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className,
)}
{...props}
/>
);
DialogFooter.displayName = "DialogFooter";
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className,
)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};

View File

@@ -0,0 +1,200 @@
"use client";
import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { Check, ChevronRight, Circle } from "lucide-react";
import { cn } from "./utils/cn";
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
inset && "pl-8",
className,
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"text-on-popover z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 shadow-md animate-in data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1",
className,
)}
{...props}
/>
));
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className,
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className,
)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
);
};
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
};

173
packages/ui/src/form.tsx Normal file
View File

@@ -0,0 +1,173 @@
"use client";
import * as React from "react";
import type * as LabelPrimitive from "@radix-ui/react-label";
import { Slot } from "@radix-ui/react-slot";
import type { ControllerProps, FieldPath, FieldValues } from "react-hook-form";
import { Controller, FormProvider, useFormContext } from "react-hook-form";
import { Label } from "./label";
import { cn } from "./utils/cn";
const Form = FormProvider;
interface FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> {
name: TName;
}
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue,
);
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
);
};
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext);
const itemContext = React.useContext(FormItemContext);
const { getFieldState, formState } = useFormContext();
const fieldState = getFieldState(fieldContext.name, formState);
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>");
}
const { id } = itemContext;
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
};
};
interface FormItemContextValue {
id: string;
}
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue,
);
const FormItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const id = React.useId();
return (
<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn("space-y-2", className)} {...props} />
</FormItemContext.Provider>
);
});
FormItem.displayName = "FormItem";
const FormLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField();
return (
<Label
ref={ref}
className={cn(error && "text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
);
});
FormLabel.displayName = "FormLabel";
const FormControl = React.forwardRef<
React.ElementRef<typeof Slot>,
React.ComponentPropsWithoutRef<typeof Slot>
>(({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } =
useFormField();
return (
<Slot
ref={ref}
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
);
});
FormControl.displayName = "FormControl";
const FormDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField();
return (
<p
ref={ref}
id={formDescriptionId}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
);
});
FormDescription.displayName = "FormDescription";
const FormMessage = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField();
const body = error ? String(error?.message) : children;
if (!body) {
return null;
}
return (
<p
ref={ref}
id={formMessageId}
className={cn("text-sm font-medium text-destructive", className)}
{...props}
>
{body}
</p>
);
});
FormMessage.displayName = "FormMessage";
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
};

206
packages/ui/src/icons.tsx Normal file
View File

@@ -0,0 +1,206 @@
import * as Lucide from "lucide-react";
import type { LucideProps } from "lucide-react";
export type Icon = (props: LucideProps) => JSX.Element;
export const Logo = Lucide.Command;
export const Dashboard = Lucide.Activity;
export const Close = Lucide.X;
export const Spinner = Lucide.Loader2;
export const ChevronLeft = Lucide.ChevronLeft;
export const ChevronRight = Lucide.ChevronRight;
export const Trash = Lucide.Trash;
export const Post = Lucide.FileText;
export const Page = Lucide.File;
export const Settings = Lucide.Settings;
export const Billing = Lucide.CreditCard;
export const Ellipsis = Lucide.MoreVertical;
export const Organization = Lucide.Building;
export const Add = Lucide.Plus;
export const Warning = Lucide.AlertTriangle;
export const User = Lucide.User;
export const ArrowRight = Lucide.ArrowRight;
export const Help = Lucide.HelpCircle;
export const Twitter = Lucide.Twitter;
export const Check = Lucide.Check;
export const Copy = Lucide.Copy;
export const CopyDone = Lucide.ClipboardCheck;
export const Sun = Lucide.SunMedium;
export const Moon = Lucide.Moon;
export const Key = Lucide.Key;
export const System: Icon = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" {...props}>
<path
d="m11.998 2c5.517 0 9.997 4.48 9.997 9.998 0 5.517-4.48 9.997-9.997 9.997-5.518 0-9.998-4.48-9.998-9.997 0-5.518 4.48-9.998 9.998-9.998zm0 1.5c-4.69 0-8.498 3.808-8.498 8.498s3.808 8.497 8.498 8.497z"
fillRule="nonzero"
fill="currentColor"
/>
</svg>
);
export const Mdx: Icon = (props) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="57.97"
height="24"
viewBox="0 0 512 212"
{...props}
>
<path
fill="currentColor"
d="m272.696 40.203l-.002 84.896l31.185-31.178l15.74 15.741l-57.642 57.638l-58.369-58.369l15.741-15.741l31.085 31.085l.001-84.072zM72.162 162.979V97.232l40.255 40.257l40.56-40.557v65.383h22.261V43.192l-62.82 62.816l-62.517-62.521v119.492z"
/>
<path
fill="#F9AC00"
d="m447.847 36.651l15.74 15.741l-47.149 47.147l45.699 45.701l-15.741 15.741l-45.7-45.699l-45.701 45.699l-15.74-15.741l45.695-45.701l-47.146-47.147l15.74-15.741l47.152 47.146z"
/>
</svg>
);
export const ClerkWide: Icon = (props) => (
<svg
width="77"
height="24"
viewBox="0 0 77 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M35.1481 16.7381C34.7521 17.1486 34.2765 17.4741 33.7505 17.6947C33.2245 17.9154 32.659 18.0265 32.0886 18.0213C31.6069 18.0359 31.1273 17.9517 30.6794 17.7739C30.2315 17.5961 29.8247 17.3285 29.4841 16.9875C28.8654 16.3421 28.5093 15.4206 28.5093 14.3221C28.5093 12.1231 29.941 10.619 32.0886 10.619C32.6646 10.6109 33.2353 10.7301 33.7599 10.968C34.2845 11.206 34.7501 11.5568 35.1234 11.9955L36.9816 10.3525C35.7707 8.8827 33.8059 8.12305 31.9401 8.12305C28.2885 8.12305 25.6992 10.64 25.6992 14.343C25.6992 16.1745 26.3427 17.7167 27.4279 18.8057C28.5131 19.8947 30.0591 20.5344 31.843 20.5344C34.16 20.5344 36.0087 19.5939 37.0463 18.4116L35.1481 16.7381Z"
fill="currentColor"
/>
<path
d="M38.7266 3.42773H41.4929V20.3398H38.7266V3.42773Z"
fill="currentColor"
/>
<path
d="M54.8179 15.2828C54.8635 14.9145 54.8889 14.5439 54.894 14.1728C54.894 10.6659 52.5979 8.12611 49.0472 8.12611C48.2641 8.11071 47.4861 8.25581 46.7612 8.55246C46.0363 8.84911 45.3797 9.29104 44.832 9.85102C43.7944 10.94 43.1719 12.4822 43.1719 14.3213C43.1719 18.07 45.8144 20.5374 49.3176 20.5374C51.6688 20.5374 53.3614 19.5855 54.3762 18.2947L52.5637 16.6897L52.4742 16.6136C52.1146 17.0634 51.6561 17.4243 51.1344 17.6683C50.6127 17.9123 50.0419 18.0328 49.4661 18.0205C47.6879 18.0205 46.4046 16.9829 46.0391 15.2828H54.8179ZM46.0848 13.0628C46.2083 12.5269 46.4613 12.0295 46.8216 11.614C47.1214 11.2874 47.4883 11.0293 47.897 10.8574C48.3058 10.6856 48.7468 10.604 49.19 10.6183C50.7702 10.6183 51.7602 11.6064 52.101 13.0628H46.0848Z"
fill="currentColor"
/>
<path
d="M63.445 8.08984V11.1741C63.1251 11.1494 62.8034 11.1246 62.6073 11.1246C60.513 11.1246 59.325 12.6287 59.325 14.603V20.3394H56.5625V8.2612H59.325V10.0908H59.3498C60.2884 8.80761 61.6344 8.09366 63.1004 8.09366L63.445 8.08984Z"
fill="currentColor"
/>
<path
d="M69.8866 15.2812L67.8894 17.5031V20.3398H65.125V3.42773H67.8894V13.8019L72.8224 8.29975H76.1046L71.7638 13.1603L76.1808 20.3398H73.0718L69.938 15.2812H69.8866Z"
fill="currentColor"
/>
<path
d="M19.116 3.1608L16.2354 6.04135C16.1449 6.13177 16.0266 6.18918 15.8996 6.20437C15.7725 6.21956 15.6441 6.19165 15.5348 6.12513C14.4017 5.44155 13.0949 5.10063 11.7722 5.14354C10.4495 5.18645 9.16759 5.61134 8.08114 6.36692C7.41295 6.83202 6.83276 7.41221 6.36765 8.0804C5.61297 9.16751 5.18848 10.4495 5.14524 11.7722C5.10201 13.0949 5.44187 14.4019 6.12395 15.536C6.19 15.6451 6.21764 15.7731 6.20246 15.8998C6.18728 16.0264 6.13015 16.1443 6.04018 16.2347L3.15962 19.1152C3.10162 19.1736 3.03168 19.2188 2.95459 19.2476C2.87751 19.2765 2.79511 19.2883 2.71302 19.2824C2.63093 19.2764 2.5511 19.2528 2.479 19.2131C2.40689 19.1734 2.34422 19.1186 2.29527 19.0524C0.736704 16.9101 -0.0687588 14.3121 0.0046021 11.6639C0.077963 9.01568 1.02602 6.46625 2.70079 4.41354C3.21208 3.78549 3.78622 3.21134 4.41428 2.70006C6.46683 1.02574 9.01589 0.0779624 11.6637 0.00460332C14.3115 -0.0687557 16.9091 0.736432 19.0512 2.29453C19.1179 2.34332 19.1731 2.40598 19.2131 2.47818C19.2532 2.55038 19.2771 2.6304 19.2833 2.71274C19.2895 2.79508 19.2777 2.87778 19.2488 2.95513C19.2199 3.03248 19.1746 3.10265 19.116 3.1608Z"
fill="#5D31FF"
/>
<path
d="M19.1135 20.8289L16.2329 17.9483C16.1424 17.8579 16.0241 17.8005 15.8971 17.7853C15.7701 17.7701 15.6416 17.798 15.5323 17.8645C14.4639 18.509 13.2398 18.8497 11.9921 18.8497C10.7443 18.8497 9.52022 18.509 8.45181 17.8645C8.34252 17.798 8.21406 17.7701 8.08701 17.7853C7.95997 17.8005 7.84171 17.8579 7.75119 17.9483L4.87063 20.8289C4.81022 20.8869 4.76333 20.9576 4.73329 21.0358C4.70324 21.114 4.69078 21.1979 4.69678 21.2815C4.70277 21.3651 4.72708 21.4463 4.76799 21.5194C4.80889 21.5926 4.86538 21.6558 4.93346 21.7046C6.98391 23.1965 9.45442 24.0001 11.9902 24.0001C14.5259 24.0001 16.9964 23.1965 19.0469 21.7046C19.1152 21.6561 19.172 21.5931 19.2133 21.5201C19.2545 21.4471 19.2792 21.366 19.2856 21.2824C19.2919 21.1988 19.2798 21.1148 19.2501 21.0365C19.2203 20.9581 19.1737 20.8872 19.1135 20.8289V20.8289Z"
fill="currentColor"
/>
<path
d="M11.9973 15.4223C13.8899 15.4223 15.4243 13.888 15.4243 11.9953C15.4243 10.1027 13.8899 8.56836 11.9973 8.56836C10.1046 8.56836 8.57031 10.1027 8.57031 11.9953C8.57031 13.888 10.1046 15.4223 11.9973 15.4223Z"
fill="currentColor"
/>
</svg>
);
export const TRPC: Icon = (props) => (
<svg
width="512"
height="512"
viewBox="0 0 512 512"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<rect width="512" height="512" rx="150" fill="currentColor" />
<path
fillRule="evenodd"
clipRule="evenodd"
className="fill-background"
d="M255.446 75L326.523 116.008V138.556L412.554 188.238V273.224L435.631 286.546V368.608L364.6 409.615L333.065 391.378L256.392 435.646L180.178 391.634L149.085 409.615L78.0538 368.538V286.546L100.231 273.743V188.238L184.415 139.638L184.462 139.636V116.008L255.446 75ZM326.523 159.879V198.023L255.492 239.031L184.462 198.023V160.936L184.415 160.938L118.692 198.9V263.084L149.085 245.538L220.115 286.546V368.538L198.626 380.965L256.392 414.323L314.618 380.712L293.569 368.538V286.546L364.6 245.538L394.092 262.565V198.9L326.523 159.879ZM312.031 357.969V307.915L355.369 332.931V382.985L312.031 357.969ZM417.169 307.846L373.831 332.862V382.985L417.169 357.9V307.846ZM96.5154 357.9V307.846L139.854 332.862V382.915L96.5154 357.9ZM201.654 307.846L158.315 332.862V382.915L201.654 357.9V307.846ZM321.262 291.923L364.6 266.908L407.938 291.923L364.6 316.962L321.262 291.923ZM149.085 266.838L105.746 291.923L149.085 316.892L192.423 291.923L149.085 266.838ZM202.923 187.362V137.308L246.215 162.346V212.377L202.923 187.362ZM308.015 137.308L264.723 162.346V212.354L308.015 187.362V137.308ZM212.154 121.338L255.446 96.3231L298.785 121.338L255.446 146.354L212.154 121.338Z"
/>
</svg>
);
export const GitHub: Icon = (props) => (
<svg viewBox="0 0 438.549 438.549" {...props}>
<path
fill="currentColor"
d="M409.132 114.573c-19.608-33.596-46.205-60.194-79.798-79.8-33.598-19.607-70.277-29.408-110.063-29.408-39.781 0-76.472 9.804-110.063 29.408-33.596 19.605-60.192 46.204-79.8 79.8C9.803 148.168 0 184.854 0 224.63c0 47.78 13.94 90.745 41.827 128.906 27.884 38.164 63.906 64.572 108.063 79.227 5.14.954 8.945.283 11.419-1.996 2.475-2.282 3.711-5.14 3.711-8.562 0-.571-.049-5.708-.144-15.417a2549.81 2549.81 0 01-.144-25.406l-6.567 1.136c-4.187.767-9.469 1.092-15.846 1-6.374-.089-12.991-.757-19.842-1.999-6.854-1.231-13.229-4.086-19.13-8.559-5.898-4.473-10.085-10.328-12.56-17.556l-2.855-6.57c-1.903-4.374-4.899-9.233-8.992-14.559-4.093-5.331-8.232-8.945-12.419-10.848l-1.999-1.431c-1.332-.951-2.568-2.098-3.711-3.429-1.142-1.331-1.997-2.663-2.568-3.997-.572-1.335-.098-2.43 1.427-3.289 1.525-.859 4.281-1.276 8.28-1.276l5.708.853c3.807.763 8.516 3.042 14.133 6.851 5.614 3.806 10.229 8.754 13.846 14.842 4.38 7.806 9.657 13.754 15.846 17.847 6.184 4.093 12.419 6.136 18.699 6.136 6.28 0 11.704-.476 16.274-1.423 4.565-.952 8.848-2.383 12.847-4.285 1.713-12.758 6.377-22.559 13.988-29.41-10.848-1.14-20.601-2.857-29.264-5.14-8.658-2.286-17.605-5.996-26.835-11.14-9.235-5.137-16.896-11.516-22.985-19.126-6.09-7.614-11.088-17.61-14.987-29.979-3.901-12.374-5.852-26.648-5.852-42.826 0-23.035 7.52-42.637 22.557-58.817-7.044-17.318-6.379-36.732 1.997-58.24 5.52-1.715 13.706-.428 24.554 3.853 10.85 4.283 18.794 7.952 23.84 10.994 5.046 3.041 9.089 5.618 12.135 7.708 17.705-4.947 35.976-7.421 54.818-7.421s37.117 2.474 54.823 7.421l10.849-6.849c7.419-4.57 16.18-8.758 26.262-12.565 10.088-3.805 17.802-4.853 23.134-3.138 8.562 21.509 9.325 40.922 2.279 58.24 15.036 16.18 22.559 35.787 22.559 58.817 0 16.178-1.958 30.497-5.853 42.966-3.9 12.471-8.941 22.457-15.125 29.979-6.191 7.521-13.901 13.85-23.131 18.986-9.232 5.14-18.182 8.85-26.84 11.136-8.662 2.286-18.415 4.004-29.263 5.146 9.894 8.562 14.842 22.077 14.842 40.539v60.237c0 3.422 1.19 6.279 3.572 8.562 2.379 2.279 6.136 2.95 11.276 1.995 44.163-14.653 80.185-41.062 108.068-79.226 27.88-38.161 41.825-81.126 41.825-128.906-.01-39.771-9.818-76.454-29.414-110.049z"
></path>
</svg>
);
export const React: Icon = (props) => (
<svg viewBox="0 0 24 24" {...props}>
<path
fill="currentColor"
d="M14.23 12.004a2.236 2.236 0 0 1-2.235 2.236 2.236 2.236 0 0 1-2.236-2.236 2.236 2.236 0 0 1 2.235-2.236 2.236 2.236 0 0 1 2.236 2.236zm2.648-10.69c-1.346 0-3.107.96-4.888 2.622-1.78-1.653-3.542-2.602-4.887-2.602-.41 0-.783.093-1.106.278-1.375.793-1.683 3.264-.973 6.365C1.98 8.917 0 10.42 0 12.004c0 1.59 1.99 3.097 5.043 4.03-.704 3.113-.39 5.588.988 6.38.32.187.69.275 1.102.275 1.345 0 3.107-.96 4.888-2.624 1.78 1.654 3.542 2.603 4.887 2.603.41 0 .783-.09 1.106-.275 1.374-.792 1.683-3.263.973-6.365C22.02 15.096 24 13.59 24 12.004c0-1.59-1.99-3.097-5.043-4.032.704-3.11.39-5.587-.988-6.38-.318-.184-.688-.277-1.092-.278zm-.005 1.09v.006c.225 0 .406.044.558.127.666.382.955 1.835.73 3.704-.054.46-.142.945-.25 1.44-.96-.236-2.006-.417-3.107-.534-.66-.905-1.345-1.727-2.035-2.447 1.592-1.48 3.087-2.292 4.105-2.295zm-9.77.02c1.012 0 2.514.808 4.11 2.28-.686.72-1.37 1.537-2.02 2.442-1.107.117-2.154.298-3.113.538-.112-.49-.195-.964-.254-1.42-.23-1.868.054-3.32.714-3.707.19-.09.4-.127.563-.132zm4.882 3.05c.455.468.91.992 1.36 1.564-.44-.02-.89-.034-1.345-.034-.46 0-.915.01-1.36.034.44-.572.895-1.096 1.345-1.565zM12 8.1c.74 0 1.477.034 2.202.093.406.582.802 1.203 1.183 1.86.372.64.71 1.29 1.018 1.946-.308.655-.646 1.31-1.013 1.95-.38.66-.773 1.288-1.18 1.87-.728.063-1.466.098-2.21.098-.74 0-1.477-.035-2.202-.093-.406-.582-.802-1.204-1.183-1.86-.372-.64-.71-1.29-1.018-1.946.303-.657.646-1.313 1.013-1.954.38-.66.773-1.286 1.18-1.868.728-.064 1.466-.098 2.21-.098zm-3.635.254c-.24.377-.48.763-.704 1.16-.225.39-.435.782-.635 1.174-.265-.656-.49-1.31-.676-1.947.64-.15 1.315-.283 2.015-.386zm7.26 0c.695.103 1.365.23 2.006.387-.18.632-.405 1.282-.66 1.933-.2-.39-.41-.783-.64-1.174-.225-.392-.465-.774-.705-1.146zm3.063.675c.484.15.944.317 1.375.498 1.732.74 2.852 1.708 2.852 2.476-.005.768-1.125 1.74-2.857 2.475-.42.18-.88.342-1.355.493-.28-.958-.646-1.956-1.1-2.98.45-1.017.81-2.01 1.085-2.964zm-13.395.004c.278.96.645 1.957 1.1 2.98-.45 1.017-.812 2.01-1.086 2.964-.484-.15-.944-.318-1.37-.5-1.732-.737-2.852-1.706-2.852-2.474 0-.768 1.12-1.742 2.852-2.476.42-.18.88-.342 1.356-.494zm11.678 4.28c.265.657.49 1.312.676 1.948-.64.157-1.316.29-2.016.39.24-.375.48-.762.705-1.158.225-.39.435-.788.636-1.18zm-9.945.02c.2.392.41.783.64 1.175.23.39.465.772.705 1.143-.695-.102-1.365-.23-2.006-.386.18-.63.406-1.282.66-1.933zM17.92 16.32c.112.493.2.968.254 1.423.23 1.868-.054 3.32-.714 3.708-.147.09-.338.128-.563.128-1.012 0-2.514-.807-4.11-2.28.686-.72 1.37-1.536 2.02-2.44 1.107-.118 2.154-.3 3.113-.54zm-11.83.01c.96.234 2.006.415 3.107.532.66.905 1.345 1.727 2.035 2.446-1.595 1.483-3.092 2.295-4.11 2.295-.22-.005-.406-.05-.553-.132-.666-.38-.955-1.834-.73-3.703.054-.46.142-.944.25-1.438zm4.56.64c.44.02.89.034 1.345.034.46 0 .915-.01 1.36-.034-.44.572-.895 1.095-1.345 1.565-.455-.47-.91-.993-1.36-1.565z"
/>
</svg>
);
export const Nextjs: Icon = (props) => (
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" {...props}>
<path
fill="currentColor"
d="M11.5725 0c-.1763 0-.3098.0013-.3584.0067-.0516.0053-.2159.021-.3636.0328-3.4088.3073-6.6017 2.1463-8.624 4.9728C1.1004 6.584.3802 8.3666.1082 10.255c-.0962.659-.108.8537-.108 1.7474s.012 1.0884.108 1.7476c.652 4.506 3.8591 8.2919 8.2087 9.6945.7789.2511 1.6.4223 2.5337.5255.3636.04 1.9354.04 2.299 0 1.6117-.1783 2.9772-.577 4.3237-1.2643.2065-.1056.2464-.1337.2183-.1573-.0188-.0139-.8987-1.1938-1.9543-2.62l-1.919-2.592-2.4047-3.5583c-1.3231-1.9564-2.4117-3.556-2.4211-3.556-.0094-.0026-.0187 1.5787-.0235 3.509-.0067 3.3802-.0093 3.5162-.0516 3.596-.061.115-.108.1618-.2064.2134-.075.0374-.1408.0445-.495.0445h-.406l-.1078-.068a.4383.4383 0 01-.1572-.1712l-.0493-.1056.0053-4.703.0067-4.7054.0726-.0915c.0376-.0493.1174-.1125.1736-.143.0962-.047.1338-.0517.5396-.0517.4787 0 .5584.0187.6827.1547.0353.0377 1.3373 1.9987 2.895 4.3608a10760.433 10760.433 0 004.7344 7.1706l1.9002 2.8782.096-.0633c.8518-.5536 1.7525-1.3418 2.4657-2.1627 1.5179-1.7429 2.4963-3.868 2.8247-6.134.0961-.6591.1078-.854.1078-1.7475 0-.8937-.012-1.0884-.1078-1.7476-.6522-4.506-3.8592-8.2919-8.2087-9.6945-.7672-.2487-1.5836-.42-2.4985-.5232-.169-.0176-1.0835-.0366-1.6123-.037zm4.0685 7.217c.3473 0 .4082.0053.4857.047.1127.0562.204.1642.237.2767.0186.061.0234 1.3653.0186 4.3044l-.0067 4.2175-.7436-1.14-.7461-1.14v-3.066c0-1.982.0093-3.0963.0234-3.1502.0375-.1313.1196-.2346.2323-.2955.0961-.0494.1313-.054.4997-.054z"
/>
</svg>
);
export const Prisma: Icon = (props) => (
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" {...props}>
<path
fill="currentColor"
d="M21.8068 18.2848L13.5528.7565c-.207-.4382-.639-.7273-1.1286-.7541-.5023-.0293-.9523.213-1.2062.6253L2.266 15.1271c-.2773.4518-.2718 1.0091.0158 1.4555l4.3759 6.7786c.2608.4046.7127.6388 1.1823.6388.1332 0 .267-.0188.3987-.0577l12.7019-3.7568c.3891-.1151.7072-.3904.8737-.7553s.1633-.7828-.0075-1.1454zm-1.8481.7519L9.1814 22.2242c-.3292.0975-.6448-.1873-.5756-.5194l3.8501-18.4386c.072-.3448.5486-.3996.699-.0803l7.1288 15.138c.1344.2856-.019.6224-.325.7128z"
/>
</svg>
);
export const Kysely: Icon = (props) => (
<svg
width="132"
height="132"
viewBox="0 0 132 132"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<g clipPath="url(#clip0_8_3)">
<rect x="2" y="2" width="128" height="128" rx="16" fill="white" />
<path
d="M41.2983 109V23.9091H46.4918V73.31H47.0735L91.9457 23.9091H98.8427L61.9062 64.1694L98.5103 109H92.0288L58.5824 67.9087L46.4918 81.2873V109H41.2983Z"
fill="black"
/>
</g>
<rect
x="2"
y="2"
width="128"
height="128"
rx="16"
stroke="#121212"
strokeWidth="4"
/>
<defs>
<clipPath id="clip0_8_3">
<rect x="2" y="2" width="128" height="128" rx="16" fill="white" />
</clipPath>
</defs>
</svg>
);
export const Tailwind: Icon = (props) => (
<svg viewBox="0 0 24 24" {...props}>
<path d="M12.001,4.8c-3.2,0-5.2,1.6-6,4.8c1.2-1.6,2.6-2.2,4.2-1.8c0.913,0.228,1.565,0.89,2.288,1.624 C13.666,10.618,15.027,12,18.001,12c3.2,0,5.2-1.6,6-4.8c-1.2,1.6-2.6,2.2-4.2,1.8c-0.913-0.228-1.565-0.89-2.288-1.624 C16.337,6.182,14.976,4.8,12.001,4.8z M6.001,12c-3.2,0-5.2,1.6-6,4.8c1.2-1.6,2.6-2.2,4.2-1.8c0.913,0.228,1.565,0.89,2.288,1.624 c1.177,1.194,2.538,2.576,5.512,2.576c3.2,0,5.2-1.6,6-4.8c-1.2,1.6-2.6,2.2-4.2,1.8c-0.913-0.228-1.565-0.89-2.288-1.624 C10.337,13.382,8.976,12,6.001,12z" />
</svg>
);
export const Google: Icon = (props) => (
<svg role="img" viewBox="0 0 24 24" {...props}>
<path
fill="currentColor"
d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z"
/>
</svg>
);

1
packages/ui/src/index.ts Normal file
View File

@@ -0,0 +1 @@
export { cn } from "./utils/cn";

24
packages/ui/src/input.tsx Normal file
View File

@@ -0,0 +1,24 @@
import * as React from "react";
import { cn } from "./utils/cn";
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
ref={ref}
{...props}
/>
);
},
);
Input.displayName = "Input";
export { Input };

27
packages/ui/src/label.tsx Normal file
View File

@@ -0,0 +1,27 @@
"use client";
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cva } from "class-variance-authority";
import type { VariantProps } from "class-variance-authority";
import { cn } from "./utils/cn";
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
);
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };

View File

@@ -0,0 +1,31 @@
"use client";
import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import { cn } from "./utils/cn";
const Popover = PopoverPrimitive.Root;
const PopoverTrigger = PopoverPrimitive.Trigger;
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none animate-in data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
</PopoverPrimitive.Portal>
));
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
export { Popover, PopoverTrigger, PopoverContent };

View File

@@ -0,0 +1,48 @@
"use client";
import * as React from "react";
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
import { cn } from "./utils/cn";
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
));
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" &&
"h-2.5 border-t border-t-transparent p-[1px]",
className,
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
));
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
export { ScrollArea, ScrollBar };

120
packages/ui/src/select.tsx Normal file
View File

@@ -0,0 +1,120 @@
"use client";
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown } from "lucide-react";
import { cn } from "./utils/cn";
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-transparent px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md animate-in fade-in-80",
position === "popper" && "translate-y-1",
className,
)}
position={position}
{...props}
>
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
)}
>
{children}
</SelectPrimitive.Viewport>
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
{...props}
/>
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
};

141
packages/ui/src/sheet.tsx Normal file
View File

@@ -0,0 +1,141 @@
"use client";
import * as React from "react";
import * as SheetPrimitive from "@radix-ui/react-dialog";
import { cva } from "class-variance-authority";
import type { VariantProps } from "class-variance-authority";
import { X } from "lucide-react";
import { cn } from "./utils/cn";
const Sheet = SheetPrimitive.Root;
const SheetTrigger = SheetPrimitive.Trigger;
const SheetClose = SheetPrimitive.Close;
const SheetPortal = SheetPrimitive.Portal;
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
ref={ref}
/>
));
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
const sheetVariants = cva(
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
{
variants: {
side: {
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
bottom:
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
right:
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
},
},
defaultVariants: {
side: "right",
},
},
);
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {}
const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>,
SheetContentProps
>(({ side = "right", className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
ref={ref}
className={cn(sheetVariants({ side }), className)}
{...props}
>
{children}
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
));
SheetContent.displayName = SheetPrimitive.Content.displayName;
const SheetHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className,
)}
{...props}
/>
);
SheetHeader.displayName = "SheetHeader";
const SheetFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className,
)}
{...props}
/>
);
SheetFooter.displayName = "SheetFooter";
const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold text-foreground", className)}
{...props}
/>
));
SheetTitle.displayName = SheetPrimitive.Title.displayName;
const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
SheetDescription.displayName = SheetPrimitive.Description.displayName;
export {
Sheet,
SheetPortal,
SheetOverlay,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
};

115
packages/ui/src/table.tsx Normal file
View File

@@ -0,0 +1,115 @@
import * as React from "react";
import { cn } from "./utils/cn";
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
));
Table.displayName = "Table";
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
));
TableHeader.displayName = "TableHeader";
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
));
TableBody.displayName = "TableBody";
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn("bg-primary font-medium text-primary-foreground", className)}
{...props}
/>
));
TableFooter.displayName = "TableFooter";
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement> & { disabled?: boolean }
>(({ className, disabled, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
disabled && "pointer-events-none text-muted-foreground opacity-80",
className,
)}
{...props}
/>
));
TableRow.displayName = "TableRow";
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
className,
)}
{...props}
/>
));
TableHead.displayName = "TableHead";
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
{...props}
/>
));
TableCell.displayName = "TableCell";
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
));
TableCaption.displayName = "TableCaption";
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
};

55
packages/ui/src/tabs.tsx Normal file
View File

@@ -0,0 +1,55 @@
"use client";
import * as React from "react";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { cn } from "./utils/cn";
const Tabs = TabsPrimitive.Root;
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
className,
)}
{...props}
/>
));
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
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 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
className,
)}
{...props}
/>
));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className,
)}
{...props}
/>
));
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent };

128
packages/ui/src/toast.tsx Normal file
View File

@@ -0,0 +1,128 @@
import * as React from "react";
import * as ToastPrimitives from "@radix-ui/react-toast";
import { cva } from "class-variance-authority";
import type { VariantProps } from "class-variance-authority";
import { X } from "lucide-react";
import { cn } from "./utils/cn";
const ToastProvider = ToastPrimitives.Provider;
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
className,
)}
{...props}
/>
));
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
const toastVariants = cva(
"data-[swipe=move]:transition-none group relative pointer-events-auto flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full data-[state=closed]:slide-out-to-right-full",
{
variants: {
variant: {
default: "bg-background border",
destructive:
"group destructive border-destructive bg-destructive text-destructive-foreground",
},
},
defaultVariants: {
variant: "default",
},
},
);
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
);
});
Toast.displayName = ToastPrimitives.Root.displayName;
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-destructive/30 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
className,
)}
{...props}
/>
));
ToastAction.displayName = ToastPrimitives.Action.displayName;
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
className,
)}
toast-close=""
{...props}
>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
));
ToastClose.displayName = ToastPrimitives.Close.displayName;
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn("text-sm font-semibold", className)}
{...props}
/>
));
ToastTitle.displayName = ToastPrimitives.Title.displayName;
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn("text-sm opacity-90", className)}
{...props}
/>
));
ToastDescription.displayName = ToastPrimitives.Description.displayName;
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>;
type ToastActionElement = React.ReactElement<typeof ToastAction>;
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
};

View File

@@ -0,0 +1,35 @@
"use client";
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
} from "./toast";
import { useToast } from "./use-toast";
export function Toaster() {
const { toasts } = useToast();
return (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && (
<ToastDescription>{description}</ToastDescription>
)}
</div>
{action}
<ToastClose />
</Toast>
);
})}
<ToastViewport />
</ToastProvider>
);
}

View File

@@ -0,0 +1,189 @@
// Inspired by react-hot-toast library
import * as React from "react";
import type { ToastActionElement, ToastProps } from "./toast";
const TOAST_LIMIT = 1;
const TOAST_REMOVE_DELAY = 1000000;
type ToasterToast = ToastProps & {
id: string;
title?: React.ReactNode;
description?: React.ReactNode;
action?: ToastActionElement;
};
const actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
} as const;
let count = 0;
function genId() {
count = (count + 1) % Number.MAX_VALUE;
return count.toString();
}
type ActionType = typeof actionTypes;
type Action =
| {
type: ActionType["ADD_TOAST"];
toast: ToasterToast;
}
| {
type: ActionType["UPDATE_TOAST"];
toast: Partial<ToasterToast>;
}
| {
type: ActionType["DISMISS_TOAST"];
toastId?: ToasterToast["id"];
}
| {
type: ActionType["REMOVE_TOAST"];
toastId?: ToasterToast["id"];
};
interface State {
toasts: ToasterToast[];
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return;
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId);
dispatch({
type: "REMOVE_TOAST",
toastId: toastId,
});
}, TOAST_REMOVE_DELAY);
toastTimeouts.set(toastId, timeout);
};
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_TOAST":
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
};
case "UPDATE_TOAST":
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t,
),
};
case "DISMISS_TOAST": {
const { toastId } = action;
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId);
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id);
});
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t,
),
};
}
case "REMOVE_TOAST":
if (action.toastId === undefined) {
return {
...state,
toasts: [],
};
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
};
}
};
const listeners: ((state: State) => void)[] = [];
let memoryState: State = { toasts: [] };
function dispatch(action: Action) {
memoryState = reducer(memoryState, action);
listeners.forEach((listener) => {
listener(memoryState);
});
}
type Toast = Omit<ToasterToast, "id">;
function toast({ ...props }: Toast) {
const id = genId();
const update = (props: ToasterToast) =>
dispatch({
type: "UPDATE_TOAST",
toast: { ...props, id },
});
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
dispatch({
type: "ADD_TOAST",
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss();
},
},
});
return {
id: id,
dismiss,
update,
};
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState);
React.useEffect(() => {
listeners.push(setState);
return () => {
const index = listeners.indexOf(setState);
if (index > -1) {
listeners.splice(index, 1);
}
};
}, [state]);
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
};
}
export { useToast, toast };

View File

@@ -0,0 +1,7 @@
import { clsx } from "clsx";
import type { ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View File

@@ -0,0 +1,12 @@
/*
* This file is not used for any compilation purpose, it is only used
* for Tailwind Intellisense & Autocompletion in the source files
*/
import type { Config } from "tailwindcss";
import baseConfig from "@acme/tailwind-config";
export default {
content: baseConfig.content,
presets: [baseConfig],
} satisfies Config;

View File

@@ -0,0 +1,8 @@
{
"extends": "@acme/tsconfig/base.json",
"compilerOptions": {
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},
"include": ["*.ts", "src"],
"exclude": ["node_modules"]
}