diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 00000000..8c1f1f56 --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,15 @@ +// This configuration only applies to the package manager root. +/** @type {import("eslint").Linter.Config} */ +module.exports = { + ignorePatterns: ["apps/**", "packages/**"], + extends: ["@repo/eslint-config/library.js"], + parser: "@typescript-eslint/parser", + parserOptions: { + project: true, + }, + // ignore some rules + rules: { + "@": "off", + "import/no-unresolved": "off", + }, +}; diff --git a/components.json b/components.json new file mode 100644 index 00000000..72e61759 --- /dev/null +++ b/components.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "tailwind.config.ts", + "css": "packages/tailwind-config/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "packages/ui/components", + "utils": "@repo/ui/lib/utils", + "ui": "packages/ui/shadcn" + } +} diff --git a/packages/eslint-config/README.md b/packages/eslint-config/README.md new file mode 100644 index 00000000..8b42d901 --- /dev/null +++ b/packages/eslint-config/README.md @@ -0,0 +1,3 @@ +# `@turbo/eslint-config` + +Collection of internal eslint configurations. diff --git a/packages/eslint-config/library.js b/packages/eslint-config/library.js new file mode 100644 index 00000000..c667cd10 --- /dev/null +++ b/packages/eslint-config/library.js @@ -0,0 +1,34 @@ +const { resolve } = require("node:path"); + +const project = resolve(process.cwd(), "tsconfig.json"); + +/** @type {import("eslint").Linter.Config} */ +module.exports = { + extends: ["eslint:recommended", "prettier", "eslint-config-turbo"], + plugins: ["only-warn"], + globals: { + React: true, + JSX: true, + }, + env: { + node: true, + }, + settings: { + "import/resolver": { + typescript: { + project, + }, + }, + }, + ignorePatterns: [ + // Ignore dotfiles + ".*.js", + "node_modules/", + "dist/", + ], + overrides: [ + { + files: ["*.js?(x)", "*.ts?(x)"], + }, + ], +}; diff --git a/packages/eslint-config/next.js b/packages/eslint-config/next.js new file mode 100644 index 00000000..429e6b14 --- /dev/null +++ b/packages/eslint-config/next.js @@ -0,0 +1,59 @@ +const { resolve } = require("node:path"); + +const project = resolve(process.cwd(), "tsconfig.json"); + +/** @type {import("eslint").Linter.Config} */ +module.exports = { + extends: [ + "next/core-web-vitals", + "plugin:@typescript-eslint/recommended-type-checked", + "plugin:@typescript-eslint/stylistic-type-checked", + "plugin:eslint-plugin-next-on-pages/recommended", + "prettier", + require.resolve("@vercel/style-guide/eslint/next"), + ], + globals: { + React: true, + JSX: true, + }, + env: { + node: true, + browser: true, + }, + plugins: ["only-warn"], + settings: { + "import/resolver": { + typescript: { + project, + }, + }, + }, + ignorePatterns: [ + // Ignore dotfiles + ".*.js", + "node_modules/", + ], + overrides: [{ files: ["*.js?(x)", "*.ts?(x)"] }], + rules: { + // These opinionated rules are enabled in stylistic-type-checked above. + // Feel free to reconfigure them to your own preference. + "@typescript-eslint/array-type": "off", + "@typescript-eslint/consistent-type-definitions": "off", + + "@typescript-eslint/consistent-type-imports": [ + "warn", + { + prefer: "type-imports", + fixStyle: "inline-type-imports", + }, + ], + "@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }], + "@typescript-eslint/require-await": "off", + "@typescript-eslint/no-misused-promises": [ + "error", + { + checksVoidReturn: { attributes: false }, + }, + ], + }, +}; diff --git a/packages/eslint-config/package.json b/packages/eslint-config/package.json new file mode 100644 index 00000000..5c51c03d --- /dev/null +++ b/packages/eslint-config/package.json @@ -0,0 +1,19 @@ +{ + "name": "@repo/eslint-config", + "version": "0.0.0", + "private": true, + "files": [ + "library.js", + "next.js", + "react-internal.js" + ], + "devDependencies": { + "@vercel/style-guide": "^5.2.0", + "eslint-config-turbo": "^1.12.4", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-only-warn": "^1.1.0", + "@typescript-eslint/parser": "^7.1.0", + "@typescript-eslint/eslint-plugin": "^7.1.0", + "typescript": "^5.3.3" + } +} diff --git a/packages/eslint-config/react-internal.js b/packages/eslint-config/react-internal.js new file mode 100644 index 00000000..0b53f12c --- /dev/null +++ b/packages/eslint-config/react-internal.js @@ -0,0 +1,39 @@ +const { resolve } = require("node:path"); + +const project = resolve(process.cwd(), "tsconfig.json"); + +/* + * This is a custom ESLint configuration for use with + * internal (bundled by their consumer) libraries + * that utilize React. + */ + +/** @type {import("eslint").Linter.Config} */ +module.exports = { + extends: ["eslint:recommended", "prettier", "eslint-config-turbo"], + plugins: ["only-warn"], + globals: { + React: true, + JSX: true, + }, + env: { + browser: true, + }, + settings: { + "import/resolver": { + typescript: { + project, + }, + }, + }, + ignorePatterns: [ + // Ignore dotfiles + ".*.js", + "node_modules/", + "dist/", + ], + overrides: [ + // Force ESLint to detect .tsx files + { files: ["*.js?(x)", "*.ts?(x)"] }, + ], +}; diff --git a/packages/shared-types/index.ts b/packages/shared-types/index.ts new file mode 100644 index 00000000..0ccc2ef7 --- /dev/null +++ b/packages/shared-types/index.ts @@ -0,0 +1,3 @@ +import { type ClientUploadedFileData } from "uploadthing/types"; + +export interface UploadedFile extends ClientUploadedFileData {} diff --git a/packages/shared-types/package.json b/packages/shared-types/package.json new file mode 100644 index 00000000..f4eaa5bd --- /dev/null +++ b/packages/shared-types/package.json @@ -0,0 +1,15 @@ +{ + "name": "@repo/shared-types", + "version": "0.0.0", + "private": true, + "type": "module", + "license": "MIT", + "publishConfig": { + "access": "public" + }, + "dependencies": { + "@repo/eslint-config": "*", + "@repo/typescript-config": "*", + "@repo/tailwind-config": "*" + } +} diff --git a/packages/shared-types/tsconfig.json b/packages/shared-types/tsconfig.json new file mode 100644 index 00000000..f0c129a6 --- /dev/null +++ b/packages/shared-types/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "@repo/typescript-config/react-library.json", + "compilerOptions": { + "outDir": "dist" + }, + "exclude": ["node_modules", "dist"] +} diff --git a/packages/tailwind-config/globals.css b/packages/tailwind-config/globals.css new file mode 100644 index 00000000..f00fd7ce --- /dev/null +++ b/packages/tailwind-config/globals.css @@ -0,0 +1,76 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 0 0% 3.9%; + + --card: 0 0% 100%; + --card-foreground: 0 0% 3.9%; + + --popover: 0 0% 100%; + --popover-foreground: 0 0% 3.9%; + + --primary: 0 0% 9%; + --primary-foreground: 0 0% 98%; + + --secondary: 0 0% 96.1%; + --secondary-foreground: 0 0% 9%; + + --muted: 0 0% 96.1%; + --muted-foreground: 0 0% 45.1%; + + --accent: 0 0% 96.1%; + --accent-foreground: 0 0% 9%; + + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 0 0% 98%; + + --border: 0 0% 89.8%; + --input: 0 0% 89.8%; + --ring: 0 0% 3.9%; + + --radius: 0.5rem; + } + + .dark { + --background: 0 0% 3.9%; + --foreground: 0 0% 98%; + + --card: 0 0% 3.9%; + --card-foreground: 0 0% 98%; + + --popover: 0 0% 3.9%; + --popover-foreground: 0 0% 98%; + + --primary: 0 0% 98%; + --primary-foreground: 0 0% 9%; + + --secondary: 0 0% 14.9%; + --secondary-foreground: 0 0% 98%; + + --muted: 0 0% 14.9%; + --muted-foreground: 0 0% 63.9%; + + --accent: 0 0% 14.9%; + --accent-foreground: 0 0% 98%; + + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 0 0% 98%; + + --border: 0 0% 14.9%; + --input: 0 0% 14.9%; + --ring: 0 0% 83.1%; + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/packages/tailwind-config/package.json b/packages/tailwind-config/package.json new file mode 100644 index 00000000..c3880c2d --- /dev/null +++ b/packages/tailwind-config/package.json @@ -0,0 +1,16 @@ +{ + "name": "@repo/tailwind-config", + "version": "0.0.0", + "private": true, + "license": "MIT", + "publishConfig": { + "access": "public" + }, + "dependencies": { + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.1", + "lucide-react": "^0.378.0", + "tailwind-merge": "^2.3.0", + "tailwindcss-animate": "^1.0.7" + } +} diff --git a/packages/tailwind-config/postcss.config.js b/packages/tailwind-config/postcss.config.js new file mode 100644 index 00000000..12a703d9 --- /dev/null +++ b/packages/tailwind-config/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/packages/tailwind-config/tailwind.config.ts b/packages/tailwind-config/tailwind.config.ts new file mode 100644 index 00000000..b70bf6b8 --- /dev/null +++ b/packages/tailwind-config/tailwind.config.ts @@ -0,0 +1,81 @@ +import type { Config } from "tailwindcss"; + +const config = { + darkMode: ["class"], + content: [ + "./pages/**/*.{ts,tsx}", + "./components/**/*.{ts,tsx}", + "./app/**/*.{ts,tsx}", + "./src/**/*.{ts,tsx}", + "../../packages/ui/**/*.{js,ts,jsx,tsx}", // Add the ui package + ], + prefix: "", + theme: { + container: { + center: true, + padding: "2rem", + screens: { + "2xl": "1400px", + }, + }, + extend: { + colors: { + border: "hsl(var(--border))", + input: "hsl(var(--input))", + ring: "hsl(var(--ring))", + background: "hsl(var(--background))", + foreground: "hsl(var(--foreground))", + primary: { + DEFAULT: "hsl(var(--primary))", + foreground: "hsl(var(--primary-foreground))", + }, + secondary: { + DEFAULT: "hsl(var(--secondary))", + foreground: "hsl(var(--secondary-foreground))", + }, + destructive: { + DEFAULT: "hsl(var(--destructive))", + foreground: "hsl(var(--destructive-foreground))", + }, + muted: { + DEFAULT: "hsl(var(--muted))", + foreground: "hsl(var(--muted-foreground))", + }, + accent: { + DEFAULT: "hsl(var(--accent))", + foreground: "hsl(var(--accent-foreground))", + }, + popover: { + DEFAULT: "hsl(var(--popover))", + foreground: "hsl(var(--popover-foreground))", + }, + card: { + DEFAULT: "hsl(var(--card))", + foreground: "hsl(var(--card-foreground))", + }, + }, + borderRadius: { + lg: "var(--radius)", + md: "calc(var(--radius) - 2px)", + sm: "calc(var(--radius) - 4px)", + }, + keyframes: { + "accordion-down": { + from: { height: "0" }, + to: { height: "var(--radix-accordion-content-height)" }, + }, + "accordion-up": { + from: { height: "var(--radix-accordion-content-height)" }, + to: { height: "0" }, + }, + }, + animation: { + "accordion-down": "accordion-down 0.2s ease-out", + "accordion-up": "accordion-up 0.2s ease-out", + }, + }, + }, + plugins: [require("tailwindcss-animate"), require("@tailwindcss/typography")], +} satisfies Config; + +export default config; diff --git a/packages/typescript-config/base.json b/packages/typescript-config/base.json new file mode 100644 index 00000000..2f1d660c --- /dev/null +++ b/packages/typescript-config/base.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "display": "Default", + "compilerOptions": { + "esModuleInterop": true, + "incremental": false, + "isolatedModules": true, + "lib": ["es2022", "DOM", "DOM.Iterable"], + "module": "NodeNext", + "moduleDetection": "force", + "moduleResolution": "NodeNext", + "noUncheckedIndexedAccess": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "strict": true, + "target": "ES2022" + } +} diff --git a/packages/typescript-config/nextjs.json b/packages/typescript-config/nextjs.json new file mode 100644 index 00000000..44f42899 --- /dev/null +++ b/packages/typescript-config/nextjs.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "display": "Next.js", + "extends": "./base.json", + "compilerOptions": { + "plugins": [{ "name": "next" }], + "module": "ESNext", + "moduleResolution": "Bundler", + "allowJs": true, + "jsx": "preserve", + "noEmit": true + } +} diff --git a/packages/typescript-config/package.json b/packages/typescript-config/package.json new file mode 100644 index 00000000..27c0e604 --- /dev/null +++ b/packages/typescript-config/package.json @@ -0,0 +1,9 @@ +{ + "name": "@repo/typescript-config", + "version": "0.0.0", + "private": true, + "license": "MIT", + "publishConfig": { + "access": "public" + } +} diff --git a/packages/typescript-config/react-library.json b/packages/typescript-config/react-library.json new file mode 100644 index 00000000..44924d9e --- /dev/null +++ b/packages/typescript-config/react-library.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "display": "React Library", + "extends": "./base.json", + "compilerOptions": { + "jsx": "react-jsx" + } +} diff --git a/packages/ui/.eslintrc.js b/packages/ui/.eslintrc.js new file mode 100644 index 00000000..46464139 --- /dev/null +++ b/packages/ui/.eslintrc.js @@ -0,0 +1,10 @@ +/** @type {import("eslint").Linter.Config} */ +module.exports = { + root: true, + extends: ["@repo/eslint-config/react-internal.js"], + parser: "@typescript-eslint/parser", + parserOptions: { + project: "./tsconfig.lint.json", + tsconfigRootDir: __dirname, + }, +}; diff --git a/packages/ui/hooks/use-callback-ref.ts b/packages/ui/hooks/use-callback-ref.ts new file mode 100644 index 00000000..e3133618 --- /dev/null +++ b/packages/ui/hooks/use-callback-ref.ts @@ -0,0 +1,24 @@ +/** + * @see https://github.com/radix-ui/primitives/blob/main/packages/react/use-callback-ref/src/useCallbackRef.tsx + */ + +import { useEffect, useMemo, useRef } from "react"; + +/** + * A custom hook that converts a callback to a ref to avoid triggering re-renders when passed as a + * prop or avoid re-executing effects when passed as a dependency + */ +function useCallbackRef unknown>( + callback: T | undefined, +): T { + const callbackRef = useRef(callback); + + useEffect(() => { + callbackRef.current = callback; + }); + + // https://github.com/facebook/react/issues/19240 + return useMemo(() => ((...args) => callbackRef.current?.(...args)) as T, []); +} + +export { useCallbackRef }; diff --git a/packages/ui/hooks/use-controllable-state.ts b/packages/ui/hooks/use-controllable-state.ts new file mode 100644 index 00000000..5ffc90f6 --- /dev/null +++ b/packages/ui/hooks/use-controllable-state.ts @@ -0,0 +1,67 @@ +import * as React from "react"; + +import { useCallbackRef } from "@repo/ui/hooks/use-callback-ref"; + +/** + * @see https://github.com/radix-ui/primitives/blob/main/packages/react/use-controllable-state/src/useControllableState.tsx + */ + +type UseControllableStateParams = { + prop?: T | undefined; + defaultProp?: T | undefined; + onChange?: (state: T) => void; +}; + +type SetStateFn = (prevState?: T) => T; + +function useControllableState({ + prop, + defaultProp, + onChange = () => {}, +}: UseControllableStateParams) { + const [uncontrolledProp, setUncontrolledProp] = useUncontrolledState({ + defaultProp, + onChange, + }); + const isControlled = prop !== undefined; + const value = isControlled ? prop : uncontrolledProp; + const handleChange = useCallbackRef(onChange); + + const setValue: React.Dispatch> = + React.useCallback( + (nextValue) => { + if (isControlled) { + const setter = nextValue as SetStateFn; + const value = + typeof nextValue === "function" ? setter(prop) : nextValue; + if (value !== prop) handleChange(value as T); + } else { + setUncontrolledProp(nextValue); + } + }, + [isControlled, prop, setUncontrolledProp, handleChange], + ); + + return [value, setValue] as const; +} + +function useUncontrolledState({ + defaultProp, + onChange, +}: Omit, "prop">) { + const uncontrolledState = React.useState(defaultProp); + const [value] = uncontrolledState; + const prevValueRef = React.useRef(value); + const handleChange = useCallbackRef(onChange); + + React.useEffect(() => { + if (prevValueRef.current !== value) { + handleChange(value as T); + prevValueRef.current = value; + } + }, [value, prevValueRef, handleChange]); + + return uncontrolledState; +} + +export { useControllableState }; diff --git a/packages/ui/hooks/use-upload-file.tsx b/packages/ui/hooks/use-upload-file.tsx new file mode 100644 index 00000000..c81d735b --- /dev/null +++ b/packages/ui/hooks/use-upload-file.tsx @@ -0,0 +1,73 @@ +import { UploadedFile } from "@repo/shared-types"; +import { useState } from "react"; + +export function useUploadFile(key: string, options?: T) { + const [isUploading, setIsUploading] = useState(false); + const [uploadedFiles, setUploadedFiles] = useState([]); + + const uploadFiles = async (image: File[]) => { + setIsUploading(true); + await Promise.all( + image.map(async (file) => { + const fileName = + file.name.split(".")[0]! + Date.now() + "." + file.name.split(".")[1]; + + const formData = new FormData(); + formData.append("data", file); + + const response = await fetch(`/api/upload_image?filename=${fileName}`, { + method: "PUT", + body: formData, + }); + + if (response.status !== 200) { + throw new Error(response.statusText); + } + + const resp = (await response.json()) as { url: string }; + const url = resp.url; + + const response2 = await fetch(url, { + method: "PUT", + body: file, + headers: { + "Content-Type": file.type, + }, + }); + + if (response2.status !== 200) { + throw new Error(response2.statusText); + } + + // For showing on the client + const uri = URL.createObjectURL(file); + + setUploadedFiles((prev) => [ + ...prev, + { + name: fileName, + url: uri, + size: file.size, + type: file.type, + customId: null, + key: fileName, + serverData: null, + }, + ]); + + return { + url, + filename: fileName, + }; + }), + ); + + setIsUploading(false); + }; + + return { + uploadFiles, + uploadedFiles, + isUploading, + }; +} diff --git a/packages/ui/lib/utils.ts b/packages/ui/lib/utils.ts new file mode 100644 index 00000000..a8758015 --- /dev/null +++ b/packages/ui/lib/utils.ts @@ -0,0 +1,49 @@ +import { type ClassValue, clsx } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} + +export function formatBytes( + bytes: number, + opts: { + decimals?: number; + sizeType?: "accurate" | "normal"; + } = {}, +) { + const { decimals = 0, sizeType = "normal" } = opts; + + const sizes = ["Bytes", "KB", "MB", "GB", "TB"]; + const accurateSizes = ["Bytes", "KiB", "MiB", "GiB", "TiB"]; + if (bytes === 0) return "0 Byte"; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + return `${(bytes / Math.pow(1024, i)).toFixed(decimals)} ${ + sizeType === "accurate" ? accurateSizes[i] ?? "Bytest" : sizes[i] ?? "Bytes" + }`; +} + +export function absoluteUrl(path: string) { + return `${process.env.NEXT_PUBLIC_APP_URL}${path}`; +} + +/** + * Stole this from the @radix-ui/primitive + * @see https://github.com/radix-ui/primitives/blob/main/packages/core/primitive/src/primitive.tsx + */ +export function composeEventHandlers( + originalEventHandler?: (event: E) => void, + ourEventHandler?: (event: E) => void, + { checkForDefaultPrevented = true } = {}, +) { + return function handleEvent(event: E) { + originalEventHandler?.(event); + + if ( + checkForDefaultPrevented === false || + !(event as unknown as Event).defaultPrevented + ) { + return ourEventHandler?.(event); + } + }; +} diff --git a/packages/ui/package.json b/packages/ui/package.json new file mode 100644 index 00000000..1487d8d5 --- /dev/null +++ b/packages/ui/package.json @@ -0,0 +1,22 @@ +{ + "name": "@repo/ui", + "version": "0.0.0", + "private": true, + "scripts": { + "lint": "eslint . --max-warnings 0", + "generate:component": "turbo gen react-component" + }, + "devDependencies": { + "@repo/eslint-config": "*", + "@repo/typescript-config": "*", + "@repo/tailwind-config": "*", + "@turbo/gen": "^1.12.4", + "@types/node": "^20.11.24", + "@types/eslint": "^8.56.5", + "@types/react": "^18.2.61", + "@types/react-dom": "^18.2.19", + "eslint": "^8.57.0", + "react": "^18.2.0", + "typescript": "^5.3.3" + } +} diff --git a/packages/ui/src/components/cardClick.tsx b/packages/ui/src/components/cardClick.tsx new file mode 100644 index 00000000..8543cb4c --- /dev/null +++ b/packages/ui/src/components/cardClick.tsx @@ -0,0 +1,78 @@ +"use client"; + +import { cn } from "@repo/ui/lib/utils"; +import { AnimatePresence, motion } from "framer-motion"; +import React from "react"; + +export const CardClick = ({ + tab, + handleClickIndex, + items, +}: { + tab: number; + handleClickIndex: (tab: number) => void; + items: { + title: string; + description: string; + svg: React.ReactNode; + }[]; +}) => { + return ( +
+ {items.map((item, idx) => ( +
handleClickIndex(idx)} + > + + {tab === idx && ( + + )} + + +
+ ))} +
+ ); +}; + +export const Card = ({ + title, + description, + svg, +}: { + title: string; + description: string; + svg: React.ReactNode; +}) => { + return ( +
+ {svg} +
+
+ {title} +
+
{description}
+
+
+ ); +}; diff --git a/packages/ui/src/components/icons.tsx b/packages/ui/src/components/icons.tsx new file mode 100644 index 00000000..a7e0ef1a --- /dev/null +++ b/packages/ui/src/components/icons.tsx @@ -0,0 +1,276 @@ +import type { SVGProps } from "react"; +export const Github = (props: SVGProps) => ( + + + +); + +export const Twitter = (props: SVGProps) => ( + + + +); + +export const Medium = (props: SVGProps) => ( + + + +); + +export const Reddit = (props: SVGProps) => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +); + +export const Notion = (props: SVGProps) => ( + + + + +); + +export const X = (props: SVGProps) => ( + + + +); diff --git a/packages/ui/src/shadcn/button.tsx b/packages/ui/src/shadcn/button.tsx new file mode 100644 index 00000000..90c4fa46 --- /dev/null +++ b/packages/ui/src/shadcn/button.tsx @@ -0,0 +1,56 @@ +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@repo/ui/lib/utils"; +import React from "react"; + +const buttonVariants = cva( + "inline-flex items-center justify-center whitespace-nowrap 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, + VariantProps { + asChild?: boolean; +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button"; + return ( + + ); + }, +); +Button.displayName = "Button"; + +export { Button, buttonVariants }; diff --git a/packages/ui/src/shadcn/card.tsx b/packages/ui/src/shadcn/card.tsx new file mode 100644 index 00000000..4e0d9363 --- /dev/null +++ b/packages/ui/src/shadcn/card.tsx @@ -0,0 +1,86 @@ +import * as React from "react"; + +import { cn } from "@repo/ui/lib/utils"; + +const Card = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +Card.displayName = "Card"; + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +CardHeader.displayName = "CardHeader"; + +const CardTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)); +CardTitle.displayName = "CardTitle"; + +const CardDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)); +CardDescription.displayName = "CardDescription"; + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)); +CardContent.displayName = "CardContent"; + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +CardFooter.displayName = "CardFooter"; + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardDescription, + CardContent, +}; diff --git a/packages/ui/src/shadcn/form.tsx b/packages/ui/src/shadcn/form.tsx new file mode 100644 index 00000000..9de2d23e --- /dev/null +++ b/packages/ui/src/shadcn/form.tsx @@ -0,0 +1,177 @@ +import * as React from "react"; +import * as LabelPrimitive from "@radix-ui/react-label"; +import { Slot } from "@radix-ui/react-slot"; +import { + Controller, + ControllerProps, + FieldPath, + FieldValues, + FormProvider, + useFormContext, +} from "react-hook-form"; + +import { cn } from "@repo/ui/lib/utils"; +import { Label } from "@repo/ui/shadcn/label"; + +const Form = FormProvider; + +type FormFieldContextValue< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +> = { + name: TName; +}; + +const FormFieldContext = React.createContext( + {} as FormFieldContextValue, +); + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +>({ + ...props +}: ControllerProps) => { + return ( + + + + ); +}; + +const useFormField = () => { + const fieldContext = React.useContext(FormFieldContext); + const itemContext = React.useContext(FormItemContext); + const { getFieldState, formState } = useFormContext(); + + const fieldState = getFieldState(fieldContext.name, formState); + + if (!fieldContext) { + throw new Error("useFormField should be used within "); + } + + const { id } = itemContext; + + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState, + }; +}; + +type FormItemContextValue = { + id: string; +}; + +const FormItemContext = React.createContext( + {} as FormItemContextValue, +); + +const FormItem = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + const id = React.useId(); + + return ( + +
+ + ); +}); +FormItem.displayName = "FormItem"; + +const FormLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + const { error, formItemId } = useFormField(); + + return ( +