diff --git a/apps/public/.gitignore b/apps/public/.gitignore new file mode 100644 index 00000000..2971a0bd --- /dev/null +++ b/apps/public/.gitignore @@ -0,0 +1,42 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# database +/prisma/db.sqlite +/prisma/db.sqlite-journal + +# next.js +/.next/ +/out/ +next-env.d.ts + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# local env files +# do not commit any .env files to git, except for the .env.example file. https://create.t3.gg/en/usage/env-variables#using-environment-variables +.env +.env*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo diff --git a/apps/public/Dockerfile b/apps/public/Dockerfile new file mode 100644 index 00000000..4ce0fd31 --- /dev/null +++ b/apps/public/Dockerfile @@ -0,0 +1,76 @@ +# Dockerfile that builds the web app only + +FROM --platform=linux/amd64 node:20-slim AS base + +ARG DATABASE_URL +ENV DATABASE_URL=$DATABASE_URL + +ENV PNPM_HOME="/pnpm" + +ENV PATH="$PNPM_HOME:$PATH" + +RUN corepack enable + +ARG NODE_VERSION=20 + +RUN apt update \ + && apt install -y curl \ + && curl -L https://raw.githubusercontent.com/tj/n/master/bin/n -o n \ + && bash n $NODE_VERSION \ + && rm n \ + && npm install -g n + +WORKDIR /app + +COPY package.json package.json +COPY pnpm-lock.yaml pnpm-lock.yaml +COPY pnpm-workspace.yaml pnpm-workspace.yaml +COPY apps/public/package.json apps/public/package.json +COPY packages/db/package.json packages/db/package.json +COPY packages/queue/package.json packages/queue/package.json +COPY packages/types/package.json packages/types/package.json + +# BUILD +FROM base AS build + +WORKDIR /app/apps/public +RUN pnpm install --frozen-lockfile --ignore-scripts + +WORKDIR /app +COPY apps apps +COPY packages packages +COPY tooling tooling +RUN pnpm db:codegen + +WORKDIR /app/apps/public +RUN pnpm run build + +# PROD +FROM base AS prod + +WORKDIR /app/apps/public +RUN pnpm install --frozen-lockfile --prod --ignore-scripts + +# FINAL +FROM base AS runner + +COPY --from=build /app/package.json /app/package.json +COPY --from=prod /app/node_modules /app/node_modules +# Apps +COPY --from=build /app/apps/public /app/apps/public +# Apps node_modules +COPY --from=prod /app/apps/public/node_modules /app/apps/public/node_modules +# Packages +COPY --from=build /app/packages/db /app/packages/db +# COPY --from=build /app/packages/queue /app/packages/queue +# Packages node_modules +COPY --from=prod /app/packages/db/node_modules /app/packages/db/node_modules +# COPY --from=prod /app/packages/queue/node_modules /app/packages/queue/node_modules + +RUN pnpm db:codegen + +WORKDIR /app/apps/public + +EXPOSE 3000 + +CMD ["pnpm", "start"] \ No newline at end of file diff --git a/apps/public/README.md b/apps/public/README.md new file mode 100644 index 00000000..fba19eda --- /dev/null +++ b/apps/public/README.md @@ -0,0 +1,28 @@ +# Create T3 App + +This is a [T3 Stack](https://create.t3.gg/) project bootstrapped with `create-t3-app`. + +## What's next? How do I make an app with this? + +We try to keep this project as simple as possible, so you can start with just the scaffolding we set up for you, and add additional things later when they become necessary. + +If you are not familiar with the different technologies used in this project, please refer to the respective docs. If you still are in the wind, please join our [Discord](https://t3.gg/discord) and ask for help. + +- [Next.js](https://nextjs.org) +- [NextAuth.js](https://next-auth.js.org) +- [Prisma](https://prisma.io) +- [Tailwind CSS](https://tailwindcss.com) +- [tRPC](https://trpc.io) + +## Learn More + +To learn more about the [T3 Stack](https://create.t3.gg/), take a look at the following resources: + +- [Documentation](https://create.t3.gg/) +- [Learn the T3 Stack](https://create.t3.gg/en/faq#what-learning-resources-are-currently-available) β€” Check out these awesome tutorials + +You can check out the [create-t3-app GitHub repository](https://github.com/t3-oss/create-t3-app) β€” your feedback and contributions are welcome! + +## How do I deploy this? + +Follow our deployment guides for [Vercel](https://create.t3.gg/en/deployment/vercel), [Netlify](https://create.t3.gg/en/deployment/netlify) and [Docker](https://create.t3.gg/en/deployment/docker) for more information. diff --git a/apps/public/components.json b/apps/public/components.json new file mode 100644 index 00000000..9750ef2a --- /dev/null +++ b/apps/public/components.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "tailwind.config.ts", + "css": "src/styles/globals.css", + "baseColor": "slate", + "cssVariables": true + }, + "aliases": { + "components": "@/components", + "utils": "@/utils/cn" + } +} diff --git a/apps/public/next.config.mjs b/apps/public/next.config.mjs new file mode 100644 index 00000000..e5bcbe13 --- /dev/null +++ b/apps/public/next.config.mjs @@ -0,0 +1,28 @@ +/** + * Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially useful + * for Docker builds. + */ +await import('./src/env.mjs'); + +/** @type {import("next").NextConfig} */ +const config = { + reactStrictMode: true, + transpilePackages: ['@mixan/queue'], + eslint: { ignoreDuringBuilds: true }, + typescript: { ignoreBuildErrors: true }, + experimental: { + // Avoid "Critical dependency: the request of a dependency is an expression" + serverComponentsExternalPackages: ['bullmq'], + }, + /** + * If you are using `appDir` then you must comment the below `i18n` config out. + * + * @see https://github.com/vercel/next.js/issues/41980 + */ + i18n: { + locales: ['en'], + defaultLocale: 'en', + }, +}; + +export default config; diff --git a/apps/public/package.json b/apps/public/package.json new file mode 100644 index 00000000..4f7121e5 --- /dev/null +++ b/apps/public/package.json @@ -0,0 +1,82 @@ +{ + "name": "@mixan/public", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "rm -rf .next && pnpm with-env next dev", + "build": "next build", + "start": "next start", + "lint": "eslint .", + "format": "prettier --write \"**/*.{tsx,mjs,ts,md,json}\"", + "typecheck": "tsc --noEmit", + "with-env": "dotenv -e ../../.env -c --" + }, + "dependencies": { + "@mixan/db": "workspace:*", + "@radix-ui/react-alert-dialog": "^1.0.5", + "@radix-ui/react-aspect-ratio": "^1.0.3", + "@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-slot": "^1.0.2", + "@radix-ui/react-toast": "^1.1.5", + "@radix-ui/react-tooltip": "^1.0.7", + "@t3-oss/env-nextjs": "^0.7.0", + "class-variance-authority": "^0.7.0", + "clsx": "^2.0.0", + "email-validator": "^2.0.4", + "embla-carousel-autoplay": "8.0.0-rc22", + "embla-carousel-react": "8.0.0-rc22", + "hamburger-react": "^2.5.0", + "lucide-react": "^0.286.0", + "next": "~14.0.4", + "nuqs": "^1.15.2", + "react": "18.2.0", + "react-animate-height": "^3.2.3", + "react-dom": "18.2.0", + "react-in-viewport": "1.0.0-alpha.30", + "react-responsive": "^9.0.2", + "react-syntax-highlighter": "^15.5.0", + "tailwind-merge": "^1.14.0", + "tailwindcss-animate": "^1.0.7", + "usehooks-ts": "^2.9.1", + "zod": "^3.22.4" + }, + "devDependencies": { + "@mixan/eslint-config": "workspace:*", + "@mixan/prettier-config": "workspace:*", + "@mixan/tsconfig": "workspace:*", + "@types/bcrypt": "^5.0.0", + "@types/lodash.debounce": "^4.0.9", + "@types/node": "^18.16.0", + "@types/ramda": "^0.29.6", + "@types/react": "^18.2.20", + "@types/react-dom": "^18.2.7", + "@types/react-syntax-highlighter": "^15.5.9", + "@typescript-eslint/eslint-plugin": "^6.6.0", + "@typescript-eslint/parser": "^6.6.0", + "autoprefixer": "^10.4.14", + "eslint": "^8.48.0", + "postcss": "^8.4.27", + "prettier": "^3.0.3", + "prettier-plugin-tailwindcss": "^0.5.1", + "tailwindcss": "^3.3.3", + "typescript": "^5.2.2" + }, + "ct3aMetadata": { + "initVersion": "7.21.0" + }, + "eslintConfig": { + "root": true, + "extends": [ + "@mixan/eslint-config/base", + "@mixan/eslint-config/react", + "@mixan/eslint-config/nextjs" + ] + }, + "prettier": "@mixan/prettier-config" +} diff --git a/apps/public/postcss.config.cjs b/apps/public/postcss.config.cjs new file mode 100644 index 00000000..e305dd92 --- /dev/null +++ b/apps/public/postcss.config.cjs @@ -0,0 +1,8 @@ +const config = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; + +module.exports = config; diff --git a/apps/public/public/demo/bar-min.png b/apps/public/public/demo/bar-min.png new file mode 100644 index 00000000..9d723b55 Binary files /dev/null and b/apps/public/public/demo/bar-min.png differ diff --git a/apps/public/public/demo/events-min.png b/apps/public/public/demo/events-min.png new file mode 100644 index 00000000..cb36a7f7 Binary files /dev/null and b/apps/public/public/demo/events-min.png differ diff --git a/apps/public/public/demo/histogram-min.png b/apps/public/public/demo/histogram-min.png new file mode 100644 index 00000000..745612c5 Binary files /dev/null and b/apps/public/public/demo/histogram-min.png differ diff --git a/apps/public/public/demo/line-min.png b/apps/public/public/demo/line-min.png new file mode 100644 index 00000000..0256db75 Binary files /dev/null and b/apps/public/public/demo/line-min.png differ diff --git a/apps/public/public/demo/metrics-min.png b/apps/public/public/demo/metrics-min.png new file mode 100644 index 00000000..fbfe7455 Binary files /dev/null and b/apps/public/public/demo/metrics-min.png differ diff --git a/apps/public/public/demo/overview-min.png b/apps/public/public/demo/overview-min.png new file mode 100644 index 00000000..5f6d2612 Binary files /dev/null and b/apps/public/public/demo/overview-min.png differ diff --git a/apps/public/public/demo/overview-share-min.png b/apps/public/public/demo/overview-share-min.png new file mode 100644 index 00000000..3228c6b1 Binary files /dev/null and b/apps/public/public/demo/overview-share-min.png differ diff --git a/apps/public/public/demo/pie-min.png b/apps/public/public/demo/pie-min.png new file mode 100644 index 00000000..d28d0a61 Binary files /dev/null and b/apps/public/public/demo/pie-min.png differ diff --git a/apps/public/public/demo/worldmap-min.png b/apps/public/public/demo/worldmap-min.png new file mode 100644 index 00000000..90b412f4 Binary files /dev/null and b/apps/public/public/demo/worldmap-min.png differ diff --git a/apps/public/public/logo.svg b/apps/public/public/logo.svg new file mode 100644 index 00000000..e45d0d95 --- /dev/null +++ b/apps/public/public/logo.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/public/src/app/api/waitlist/route.ts b/apps/public/src/app/api/waitlist/route.ts new file mode 100644 index 00000000..b221590e --- /dev/null +++ b/apps/public/src/app/api/waitlist/route.ts @@ -0,0 +1,28 @@ +import * as EmailValidator from 'email-validator'; +// true + +import { NextResponse } from 'next/server'; + +import { db } from '@mixan/db'; + +EmailValidator.validate('test@email.com'); + +export async function POST(req: Request) { + const body = await req.json(); + + if (!body.email) { + return NextResponse.json({ error: 'Email is required' }, { status: 400 }); + } + + if (!EmailValidator.validate(body.email)) { + return NextResponse.json({ error: 'Email is not valid' }, { status: 400 }); + } + + await db.waitlist.create({ + data: { + email: body.email.toLowerCase(), + }, + }); + + return NextResponse.json(body); +} diff --git a/apps/public/src/app/carousel.tsx b/apps/public/src/app/carousel.tsx new file mode 100644 index 00000000..f26569d0 --- /dev/null +++ b/apps/public/src/app/carousel.tsx @@ -0,0 +1,72 @@ +'use client'; + +import { + Carousel, + CarouselContent, + CarouselItem, + CarouselNext, + CarouselPrevious, +} from '@/components/ui/carousel'; +import Autoplay from 'embla-carousel-autoplay'; +import Image from 'next/image'; + +const images = [ + { + title: 'Beautiful overview, everything is clickable to get more details', + url: '/demo/overview-min.png', + }, + { + title: 'Histogram, perfect for showing active users', + url: '/demo/histogram-min.png', + }, + { title: 'Make your overview public', url: '/demo/overview-share-min.png' }, + { + title: 'See real time events from your users', + url: '/demo/events-min.png', + }, + { title: 'The classic line chart', url: '/demo/line-min.png' }, + { + title: 'Bar charts to see your most popular content', + url: '/demo/bar-min.png', + }, + { title: 'Get nice metric cards with graphs', url: '/demo/metrics-min.png' }, + { title: 'See where your events comes from', url: '/demo/worldmap-min.png' }, + { title: 'The classic pie chart', url: '/demo/pie-min.png' }, +]; + +export function HomeCarousel() { + return ( +
+
+
+ + + {images.map((item) => ( + +
+ {item.title} +
+
+ ))} +
+ + +
+
+
+ ); +} diff --git a/apps/public/src/app/copy.tsx b/apps/public/src/app/copy.tsx new file mode 100644 index 00000000..5f2b808b --- /dev/null +++ b/apps/public/src/app/copy.tsx @@ -0,0 +1,34 @@ +import { cn } from '@/utils/cn'; + +interface Props { + children: React.ReactNode; + className?: string; +} + +export function Lead({ children, className }: Props) { + return ( +

+ {children} +

+ ); +} + +export function Paragraph({ children, className }: Props) { + return

{children}

; +} + +export function Heading1({ children, className }: Props) { + return ( +

+ {children} +

+ ); +} + +export function Heading2({ children, className }: Props) { + return ( +

+ {children} +

+ ); +} diff --git a/apps/public/src/app/favicon.ico b/apps/public/src/app/favicon.ico new file mode 100644 index 00000000..dd5ab726 Binary files /dev/null and b/apps/public/src/app/favicon.ico differ diff --git a/apps/public/src/app/join-waitlist.tsx b/apps/public/src/app/join-waitlist.tsx new file mode 100644 index 00000000..5592104a --- /dev/null +++ b/apps/public/src/app/join-waitlist.tsx @@ -0,0 +1,65 @@ +'use client'; + +import { useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; + +export function JoinWaitlist() { + const [value, setValue] = useState(''); + const [open, setOpen] = useState(false); + return ( + <> + + + + Thanks so much! + + You're now on the waiting list. We'll let you know when we're + ready. Should be within a month or two πŸš€ + + + + + + + + +
{ + e.preventDefault(); + fetch('/api/waitlist', { + method: 'POST', + body: JSON.stringify({ email: value }), + headers: { + 'Content-Type': 'application/json', + }, + }).then((res) => { + if (res.ok) { + setOpen(true); + } + }); + }} + > +
+ setValue(e.target.value)} + /> + +
+
+ + ); +} diff --git a/apps/public/src/app/layout.tsx b/apps/public/src/app/layout.tsx new file mode 100644 index 00000000..a6f0af4a --- /dev/null +++ b/apps/public/src/app/layout.tsx @@ -0,0 +1,28 @@ +import { cn } from '@/utils/cn'; + +import '@/styles/globals.css'; + +import type { Metadata } from 'next'; + +import { defaultMeta } from './meta'; + +export const metadata: Metadata = { + ...defaultMeta, + alternates: { + canonical: 'https://openpanel.dev', + }, +}; + +export default async function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + + {children} + + + ); +} diff --git a/apps/public/src/app/manifest.ts b/apps/public/src/app/manifest.ts new file mode 100644 index 00000000..778ac20b --- /dev/null +++ b/apps/public/src/app/manifest.ts @@ -0,0 +1,22 @@ +import type { MetadataRoute } from 'next'; + +import { defaultMeta } from './meta'; + +export default function manifest(): MetadataRoute.Manifest { + return { + name: defaultMeta.title as string, + short_name: 'Openpanel.dev', + description: defaultMeta.description!, + start_url: '/', + display: 'standalone', + background_color: '#fff', + theme_color: '#fff', + icons: [ + { + src: '/favicon.ico', + sizes: 'any', + type: 'image/x-icon', + }, + ], + }; +} diff --git a/apps/public/src/app/meta.ts b/apps/public/src/app/meta.ts new file mode 100644 index 00000000..ee4951fe --- /dev/null +++ b/apps/public/src/app/meta.ts @@ -0,0 +1,7 @@ +import type { Metadata } from 'next'; + +export const defaultMeta: Metadata = { + title: 'Openpanel.dev | A Open-Source Analytics Library', + description: + 'Unlock actionable insights effortlessly with Insightful, the open-source analytics library that combines the power of Mixpanel with the simplicity of Plausible. Enjoy a unified overview, predictable pricing, and a vibrant community. Join us in democratizing analytics today!', +}; diff --git a/apps/public/src/app/page.tsx b/apps/public/src/app/page.tsx new file mode 100644 index 00000000..6d4ec140 --- /dev/null +++ b/apps/public/src/app/page.tsx @@ -0,0 +1,158 @@ +import { Logo } from '@/components/Logo'; +import { Button } from '@/components/ui/button'; +import { + BarChart2, + CookieIcon, + Globe2Icon, + LayoutPanelTopIcon, + LockIcon, + UsersIcon, +} from 'lucide-react'; + +import { HomeCarousel } from './carousel'; +import { Heading1, Heading2, Lead, Paragraph } from './copy'; +import { JoinWaitlist } from './join-waitlist'; + +const features = [ + { + title: 'Great overview', + icon: LayoutPanelTopIcon, + }, + { + title: 'Beautiful charts', + icon: BarChart2, + }, + { + title: 'Privacy focused', + icon: LockIcon, + }, + { + title: 'Open-source', + icon: Globe2Icon, + }, + { + title: 'No cookies', + icon: CookieIcon, + }, + { + title: 'User journey', + icon: UsersIcon, + }, +]; + +export default function Page() { + return ( +
+
+
+ +
+
+ +
+
+ + A open-source +
+ alternative to Mixpanel +
+ + Combine Mixpanel and Plausible and you get Openpanel. A simple + analytics tool that respects privacy. + + +
+ {features.map(({ icon: Icon, title }) => ( +
+ + {title} +
+ ))} +
+
+
+
+ + Get a feel how it looks + + + We've crafted a clean and intuitive interface because analytics should +
+ be straightforward, unlike the complexity often associated with Google + Analytics. πŸ˜… +
+ +
+
+ Another analytic tool? +
+ + TL;DR Our open-source analytic library fills a + crucial gap by combining the strengths of Mixpanel's powerful + features with Plausible's clear overview page. Motivated by the lack + of an open-source alternative to Mixpanel and inspired by + Plausible's simplicity, we aim to create an intuitive platform with + predictable pricing. With a single-tier pricing model and limits + only on monthly event counts, our goal is to democratize analytics, + offering unrestricted access to all features while ensuring + affordability and transparency for users of all project sizes. + + +
+
+
+
+
+
+ + + Our open-source analytic library emerged from a clear need within + the analytics community. While platforms like Mixpanel offer + powerful and user-friendly features, they lack a comprehensive + overview page akin to Plausible's, which succinctly summarizes + essential metrics. Recognizing this gap, we saw an opportunity to + combine the strengths of both platforms while addressing their + respective shortcomings. + + + + One significant motivation behind our endeavor was the absence of an + open-source alternative to Mixpanel. We believe in the importance of + accessibility and transparency in analytics, which led us to embark + on creating a solution that anyone can freely use and contribute to. + + + + Inspired by Plausible's exemplary approach to simplicity and + clarity, we aim to build upon their foundation and further refine + the user experience. By harnessing the best practices demonstrated + by Plausible, we aspire to create an intuitive and streamlined + analytics platform that empowers users to derive actionable insights + effortlessly. + + + + Our own experiences with traditional analytics platforms like + Mixpanel underscored another critical aspect driving our project: + the need for predictable pricing. As project owners ourselves, we + encountered the frustration of escalating costs as our user base + grew. Therefore, we are committed to offering a single-tier pricing + model that provides unlimited access to all features without the + fear of unexpected expenses. + + + + In line with our commitment to fairness and accessibility, our + pricing model will only impose limits on the number of events users + can send each month. This approach, akin to Plausible's, ensures + that users have the freedom to explore and utilize our platform to + its fullest potential without arbitrary restrictions on reports or + user counts. Ultimately, our goal is to democratize analytics by + offering a reliable, transparent, and cost-effective solution for + projects of all sizes. + +
+
+
+ ); +} diff --git a/apps/public/src/components/Logo.tsx b/apps/public/src/components/Logo.tsx new file mode 100644 index 00000000..579ef078 --- /dev/null +++ b/apps/public/src/components/Logo.tsx @@ -0,0 +1,23 @@ +import { cn } from '@/utils/cn'; +import Image from 'next/image'; + +interface LogoProps { + className?: string; +} + +export function Logo({ className }: LogoProps) { + return ( +
+ Openpanel logo + openpanel.dev +
+ ); +} diff --git a/apps/public/src/components/ui/RenderDots.tsx b/apps/public/src/components/ui/RenderDots.tsx new file mode 100644 index 00000000..267c42e7 --- /dev/null +++ b/apps/public/src/components/ui/RenderDots.tsx @@ -0,0 +1,54 @@ +'use client'; + +import { cn } from '@/utils/cn'; +import { Asterisk, ChevronRight } from 'lucide-react'; + +import { Tooltip, TooltipContent, TooltipTrigger } from './tooltip'; + +interface RenderDotsProps extends React.HTMLAttributes { + children: string; + truncate?: boolean; +} + +export function RenderDots({ + children, + className, + truncate, + ...props +}: RenderDotsProps) { + const parts = children.split('.'); + const sliceAt = truncate && parts.length > 3 ? 3 : 0; + return ( + + +
+ {parts.slice(-sliceAt).map((str, index) => { + return ( +
+ {index !== 0 && ( + + )} + {str.includes('[*]') ? ( + <> + {str.replace('[*]', '')} + + + ) : str === '*' ? ( + + ) : ( + str + )} +
+ ); + })} +
+
+ +

{children}

+
+
+ ); +} diff --git a/apps/public/src/components/ui/alert-dialog.tsx b/apps/public/src/components/ui/alert-dialog.tsx new file mode 100644 index 00000000..463dc1e2 --- /dev/null +++ b/apps/public/src/components/ui/alert-dialog.tsx @@ -0,0 +1,140 @@ +'use client'; + +import * as React from 'react'; +import { buttonVariants } from '@/components/ui/button'; +import { cn } from '@/utils/cn'; +import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'; + +const AlertDialog = AlertDialogPrimitive.Root; + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger; + +const AlertDialogPortal = AlertDialogPrimitive.Portal; + +const AlertDialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + +)); +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName; + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)); +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName; + +const AlertDialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +AlertDialogHeader.displayName = 'AlertDialogHeader'; + +const AlertDialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +AlertDialogFooter.displayName = 'AlertDialogFooter'; + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName; + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogDescription.displayName = + AlertDialogPrimitive.Description.displayName; + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName; + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName; + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +}; diff --git a/apps/public/src/components/ui/alert.tsx b/apps/public/src/components/ui/alert.tsx new file mode 100644 index 00000000..27093d2a --- /dev/null +++ b/apps/public/src/components/ui/alert.tsx @@ -0,0 +1,59 @@ +import * as React from 'react'; +import { cn } from '@/utils/cn'; +import { cva } from 'class-variance-authority'; +import type { VariantProps } from 'class-variance-authority'; + +const alertVariants = cva( + 'relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground', + { + variants: { + variant: { + default: 'bg-background text-foreground', + destructive: + 'border-destructive text-destructive dark:border-destructive [&>svg]:text-destructive', + }, + }, + defaultVariants: { + variant: 'default', + }, + } +); + +const Alert = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & VariantProps +>(({ className, variant, ...props }, ref) => ( +
+)); +Alert.displayName = 'Alert'; + +const AlertTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +AlertTitle.displayName = 'AlertTitle'; + +const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +AlertDescription.displayName = 'AlertDescription'; + +export { Alert, AlertTitle, AlertDescription }; diff --git a/apps/public/src/components/ui/aspect-ratio.tsx b/apps/public/src/components/ui/aspect-ratio.tsx new file mode 100644 index 00000000..aaabffbc --- /dev/null +++ b/apps/public/src/components/ui/aspect-ratio.tsx @@ -0,0 +1,7 @@ +'use client'; + +import * as AspectRatioPrimitive from '@radix-ui/react-aspect-ratio'; + +const AspectRatio = AspectRatioPrimitive.Root; + +export { AspectRatio }; diff --git a/apps/public/src/components/ui/avatar.tsx b/apps/public/src/components/ui/avatar.tsx new file mode 100644 index 00000000..2817f1c6 --- /dev/null +++ b/apps/public/src/components/ui/avatar.tsx @@ -0,0 +1,49 @@ +'use client'; + +import * as React from 'react'; +import { cn } from '@/utils/cn'; +import * as AvatarPrimitive from '@radix-ui/react-avatar'; + +const Avatar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +Avatar.displayName = AvatarPrimitive.Root.displayName; + +const AvatarImage = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AvatarImage.displayName = AvatarPrimitive.Image.displayName; + +const AvatarFallback = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName; + +export { Avatar, AvatarImage, AvatarFallback }; diff --git a/apps/public/src/components/ui/badge.tsx b/apps/public/src/components/ui/badge.tsx new file mode 100644 index 00000000..60b45ea9 --- /dev/null +++ b/apps/public/src/components/ui/badge.tsx @@ -0,0 +1,40 @@ +'use client'; + +import * as React from 'react'; +import { cn } from '@/utils/cn'; +import { cva } from 'class-variance-authority'; +import type { VariantProps } from 'class-variance-authority'; + +const badgeVariants = cva( + 'inline-flex items-center rounded-full border px-1.5 h-[20px] text-[10px] font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2', + { + variants: { + variant: { + default: + 'border-transparent bg-primary text-primary-foreground hover:bg-primary/80', + secondary: + 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80', + destructive: + 'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80', + success: + 'border-transparent bg-emerald-500 text-emerald-100 hover:bg-emerald-500/80', + outline: 'text-foreground', + }, + }, + defaultVariants: { + variant: 'default', + }, + } +); + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ); +} + +export { Badge, badgeVariants }; diff --git a/apps/public/src/components/ui/button.tsx b/apps/public/src/components/ui/button.tsx new file mode 100644 index 00000000..0ab333b1 --- /dev/null +++ b/apps/public/src/components/ui/button.tsx @@ -0,0 +1,86 @@ +'use client'; + +import * as React from 'react'; +import { cn } from '@/utils/cn'; +import { Slot } from '@radix-ui/react-slot'; +import { cva } from 'class-variance-authority'; +import type { VariantProps } from 'class-variance-authority'; +import type { LucideIcon } from 'lucide-react'; +import { Loader2 } from 'lucide-react'; + +const buttonVariants = cva( + 'flex-shrink-0 inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background whitespace-nowrap 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', + cta: 'bg-blue-600 text-primary-foreground hover:bg-blue-500', + 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; + loading?: boolean; + icon?: LucideIcon; +} + +const Button = React.forwardRef( + ( + { + className, + variant, + size, + asChild = false, + children, + loading, + disabled, + icon, + ...props + }, + ref + ) => { + const Comp = asChild ? Slot : 'button'; + const Icon = loading ? Loader2 : icon ?? null; + return ( + + {Icon && ( + + )} + {children} + + ); + } +); +Button.displayName = 'Button'; +Button.defaultProps = { + type: 'button', +}; + +export { Button, buttonVariants }; diff --git a/apps/public/src/components/ui/carousel.tsx b/apps/public/src/components/ui/carousel.tsx new file mode 100644 index 00000000..80f28c1c --- /dev/null +++ b/apps/public/src/components/ui/carousel.tsx @@ -0,0 +1,260 @@ +import * as React from "react" +import useEmblaCarousel, { + type UseEmblaCarouselType, +} from "embla-carousel-react" +import { ArrowLeft, ArrowRight } from "lucide-react" + +import { cn } from "@/utils/cn" +import { Button } from "@/components/ui/button" + +type CarouselApi = UseEmblaCarouselType[1] +type UseCarouselParameters = Parameters +type CarouselOptions = UseCarouselParameters[0] +type CarouselPlugin = UseCarouselParameters[1] + +type CarouselProps = { + opts?: CarouselOptions + plugins?: CarouselPlugin + orientation?: "horizontal" | "vertical" + setApi?: (api: CarouselApi) => void +} + +type CarouselContextProps = { + carouselRef: ReturnType[0] + api: ReturnType[1] + scrollPrev: () => void + scrollNext: () => void + canScrollPrev: boolean + canScrollNext: boolean +} & CarouselProps + +const CarouselContext = React.createContext(null) + +function useCarousel() { + const context = React.useContext(CarouselContext) + + if (!context) { + throw new Error("useCarousel must be used within a ") + } + + return context +} + +const Carousel = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & CarouselProps +>( + ( + { + orientation = "horizontal", + opts, + setApi, + plugins, + className, + children, + ...props + }, + ref + ) => { + const [carouselRef, api] = useEmblaCarousel( + { + ...opts, + axis: orientation === "horizontal" ? "x" : "y", + }, + plugins + ) + const [canScrollPrev, setCanScrollPrev] = React.useState(false) + const [canScrollNext, setCanScrollNext] = React.useState(false) + + const onSelect = React.useCallback((api: CarouselApi) => { + if (!api) { + return + } + + setCanScrollPrev(api.canScrollPrev()) + setCanScrollNext(api.canScrollNext()) + }, []) + + const scrollPrev = React.useCallback(() => { + api?.scrollPrev() + }, [api]) + + const scrollNext = React.useCallback(() => { + api?.scrollNext() + }, [api]) + + const handleKeyDown = React.useCallback( + (event: React.KeyboardEvent) => { + if (event.key === "ArrowLeft") { + event.preventDefault() + scrollPrev() + } else if (event.key === "ArrowRight") { + event.preventDefault() + scrollNext() + } + }, + [scrollPrev, scrollNext] + ) + + React.useEffect(() => { + if (!api || !setApi) { + return + } + + setApi(api) + }, [api, setApi]) + + React.useEffect(() => { + if (!api) { + return + } + + onSelect(api) + api.on("reInit", onSelect) + api.on("select", onSelect) + + return () => { + api?.off("select", onSelect) + } + }, [api, onSelect]) + + return ( + +
+ {children} +
+
+ ) + } +) +Carousel.displayName = "Carousel" + +const CarouselContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + const { carouselRef, orientation } = useCarousel() + + return ( +
+
+
+ ) +}) +CarouselContent.displayName = "CarouselContent" + +const CarouselItem = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + const { orientation } = useCarousel() + + return ( +
+ ) +}) +CarouselItem.displayName = "CarouselItem" + +const CarouselPrevious = React.forwardRef< + HTMLButtonElement, + React.ComponentProps +>(({ className, variant = "outline", size = "icon", ...props }, ref) => { + const { orientation, scrollPrev, canScrollPrev } = useCarousel() + + return ( + + ) +}) +CarouselPrevious.displayName = "CarouselPrevious" + +const CarouselNext = React.forwardRef< + HTMLButtonElement, + React.ComponentProps +>(({ className, variant = "outline", size = "icon", ...props }, ref) => { + const { orientation, scrollNext, canScrollNext } = useCarousel() + + return ( + + ) +}) +CarouselNext.displayName = "CarouselNext" + +export { + type CarouselApi, + Carousel, + CarouselContent, + CarouselItem, + CarouselPrevious, + CarouselNext, +} diff --git a/apps/public/src/components/ui/checkbox.tsx b/apps/public/src/components/ui/checkbox.tsx new file mode 100644 index 00000000..f5253de3 --- /dev/null +++ b/apps/public/src/components/ui/checkbox.tsx @@ -0,0 +1,29 @@ +'use client'; + +import * as React from 'react'; +import { cn } from '@/utils/cn'; +import * as CheckboxPrimitive from '@radix-ui/react-checkbox'; +import { Check } from 'lucide-react'; + +const Checkbox = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + +)); +Checkbox.displayName = CheckboxPrimitive.Root.displayName; + +export { Checkbox }; diff --git a/apps/public/src/components/ui/combobox-advanced.tsx b/apps/public/src/components/ui/combobox-advanced.tsx new file mode 100644 index 00000000..8a608580 --- /dev/null +++ b/apps/public/src/components/ui/combobox-advanced.tsx @@ -0,0 +1,122 @@ +'use client'; + +import * as React from 'react'; +import { Badge } from '@/components/ui/badge'; +import { Command, CommandGroup, CommandItem } from '@/components/ui/command'; +import { useOnClickOutside } from 'usehooks-ts'; + +import { Checkbox } from './checkbox'; +import { Input } from './input'; + +type IValue = any; +type IItem = Record<'value' | 'label', IValue>; + +interface ComboboxAdvancedProps { + value: IValue[]; + onChange: React.Dispatch>; + items: IItem[]; + placeholder: string; +} + +export function ComboboxAdvanced({ + items, + value, + onChange, + placeholder, +}: ComboboxAdvancedProps) { + const [open, setOpen] = React.useState(false); + const [inputValue, setInputValue] = React.useState(''); + const ref = React.useRef(null); + useOnClickOutside(ref, () => setOpen(false)); + + const selectables = items + .filter((item) => !value.find((s) => s === item.value)) + .filter( + (item) => + (typeof item.label === 'string' && + item.label.toLowerCase().includes(inputValue.toLowerCase())) || + (typeof item.value === 'string' && + item.value.toLowerCase().includes(inputValue.toLowerCase())) + ); + + const renderItem = (item: IItem) => { + const checked = !!value.find((s) => s === item.value); + return ( + { + e.preventDefault(); + e.stopPropagation(); + }} + onSelect={() => { + setInputValue(''); + onChange((prev) => { + if (prev.includes(item.value)) { + return prev.filter((s) => s !== item.value); + } + return [...prev, item.value]; + }); + }} + className={'cursor-pointer flex items-center gap-2'} + > + + {item?.label ?? item?.value} + + ); + }; + + const renderUnknownItem = (value: IValue) => { + const item = items.find((item) => item.value === value); + return item ? renderItem(item) : renderItem({ value, label: value }); + }; + + return ( + + + {open && ( +
+
+ +
+ setInputValue(event.target.value)} + /> +
+ {inputValue === '' + ? value.map(renderUnknownItem) + : renderItem({ + value: inputValue, + label: `Pick "${inputValue}"`, + })} + {selectables.map(renderItem)} +
+
+
+ )} +
+ ); +} diff --git a/apps/public/src/components/ui/combobox-multi.tsx b/apps/public/src/components/ui/combobox-multi.tsx new file mode 100644 index 00000000..34a132a9 --- /dev/null +++ b/apps/public/src/components/ui/combobox-multi.tsx @@ -0,0 +1,125 @@ +'use client'; + +import * as React from 'react'; +import { Badge } from '@/components/ui/badge'; +import { Command, CommandGroup, CommandItem } from '@/components/ui/command'; +import { Command as CommandPrimitive } from 'cmdk'; +import { X } from 'lucide-react'; + +type Item = Record<'value' | 'label', string>; + +interface ComboboxMultiProps { + selected: Item[]; + setSelected: React.Dispatch>; + items: Item[]; + placeholder: string; +} + +export function ComboboxMulti({ + items, + selected, + setSelected, + placeholder, + ...props +}: ComboboxMultiProps) { + const inputRef = React.useRef(null); + const [open, setOpen] = React.useState(false); + const [inputValue, setInputValue] = React.useState(''); + + const handleUnselect = React.useCallback((item: Item) => { + setSelected((prev) => prev.filter((s) => s.value !== item.value)); + }, []); + + const handleKeyDown = React.useCallback( + (e: React.KeyboardEvent) => { + const input = inputRef.current; + if (input) { + if (e.key === 'Delete' || e.key === 'Backspace') { + if (input.value === '') { + setSelected((prev) => { + const newSelected = [...prev]; + newSelected.pop(); + return newSelected; + }); + } + } + // This is not a default behaviour of the field + if (e.key === 'Escape') { + input.blur(); + } + } + }, + [] + ); + + const selectables = items.filter( + (item) => !selected.find((s) => s.value === item.value) + ); + + return ( + +
+
+ {selected.map((item) => { + return ( + + {item.label} + + + ); + })} + {/* Avoid having the "Search" Icon */} + setOpen(false)} + onFocus={() => setOpen(true)} + placeholder={placeholder} + className="ml-2 bg-transparent outline-none placeholder:text-muted-foreground flex-1" + /> +
+
+
+ {open && selectables.length > 0 ? ( +
+ + {selectables.map((item) => { + return ( + { + e.preventDefault(); + e.stopPropagation(); + }} + onSelect={(value) => { + setInputValue(''); + setSelected((prev) => [...prev, item]); + }} + className={'cursor-pointer'} + > + {item.label} + + ); + })} + +
+ ) : null} +
+
+ ); +} diff --git a/apps/public/src/components/ui/combobox.tsx b/apps/public/src/components/ui/combobox.tsx new file mode 100644 index 00000000..a939345a --- /dev/null +++ b/apps/public/src/components/ui/combobox.tsx @@ -0,0 +1,139 @@ +'use client'; + +import * as React from 'react'; +import type { ButtonProps } from '@/components/ui/button'; +import { Button } from '@/components/ui/button'; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, +} from '@/components/ui/command'; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover'; +import { cn } from '@/utils/cn'; +import type { LucideIcon } from 'lucide-react'; +import { Check, ChevronsUpDown } from 'lucide-react'; + +export interface ComboboxProps { + placeholder: string; + items: { + value: T; + label: string; + disabled?: boolean; + }[]; + value: T | null | undefined; + onChange: (value: T) => void; + children?: React.ReactNode; + onCreate?: (value: T) => void; + className?: string; + searchable?: boolean; + icon?: LucideIcon; + size?: ButtonProps['size']; + label?: string; +} + +export type ExtendedComboboxProps = Omit< + ComboboxProps, + 'items' | 'placeholder' +> & { + placeholder?: string; +}; + +export function Combobox({ + placeholder, + items, + value, + onChange, + children, + onCreate, + className, + searchable, + icon: Icon, + size, + label, +}: ComboboxProps) { + const [open, setOpen] = React.useState(false); + const [search, setSearch] = React.useState(''); + function find(value: string) { + return items.find( + (item) => item.value.toLowerCase() === value.toLowerCase() + ); + } + + return ( + + + {children ?? ( + + )} + + + + {searchable === true && ( + + )} + {typeof onCreate === 'function' && search ? ( + + + + ) : ( + Nothing selected + )} +
+ + {items.map((item) => ( + { + const value = find(currentValue)?.value ?? currentValue; + onChange(value as T); + setOpen(false); + }} + {...(item.disabled && { disabled: true })} + > + + {item.label} + + ))} + +
+
+
+
+ ); +} diff --git a/apps/public/src/components/ui/command.tsx b/apps/public/src/components/ui/command.tsx new file mode 100644 index 00000000..91b9f2af --- /dev/null +++ b/apps/public/src/components/ui/command.tsx @@ -0,0 +1,155 @@ +'use client'; + +import * as React from 'react'; +import { Dialog, DialogContent } from '@/components/ui/dialog'; +import { cn } from '@/utils/cn'; +import type { DialogProps } from '@radix-ui/react-dialog'; +import { Command as CommandPrimitive } from 'cmdk'; +import { Search } from 'lucide-react'; + +const Command = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +Command.displayName = CommandPrimitive.displayName; + +type CommandDialogProps = DialogProps; + +const CommandDialog = ({ children, ...props }: CommandDialogProps) => { + return ( + + + + {children} + + + + ); +}; + +const CommandInput = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( +
+ + +
+)); + +CommandInput.displayName = CommandPrimitive.Input.displayName; + +const CommandList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); + +CommandList.displayName = CommandPrimitive.List.displayName; + +const CommandEmpty = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>((props, ref) => ( + +)); + +CommandEmpty.displayName = CommandPrimitive.Empty.displayName; + +const CommandGroup = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); + +CommandGroup.displayName = CommandPrimitive.Group.displayName; + +const CommandSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +CommandSeparator.displayName = CommandPrimitive.Separator.displayName; + +const CommandItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); + +CommandItem.displayName = CommandPrimitive.Item.displayName; + +const CommandShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ); +}; +CommandShortcut.displayName = 'CommandShortcut'; + +export { + Command, + CommandDialog, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, + CommandShortcut, + CommandSeparator, +}; diff --git a/apps/public/src/components/ui/dialog.tsx b/apps/public/src/components/ui/dialog.tsx new file mode 100644 index 00000000..fe85da54 --- /dev/null +++ b/apps/public/src/components/ui/dialog.tsx @@ -0,0 +1,121 @@ +'use client'; + +import * as React from 'react'; +import { cn } from '@/utils/cn'; +import * as DialogPrimitive from '@radix-ui/react-dialog'; +import { X } from 'lucide-react'; + +const Dialog = DialogPrimitive.Root; + +const DialogTrigger = DialogPrimitive.Trigger; + +const DialogPortal = DialogPrimitive.Portal; + +const DialogClose = DialogPrimitive.Close; + +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)); +DialogContent.displayName = DialogPrimitive.Content.displayName; + +const DialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +DialogHeader.displayName = 'DialogHeader'; + +const DialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +DialogFooter.displayName = 'DialogFooter'; + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogTitle.displayName = DialogPrimitive.Title.displayName; + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogDescription.displayName = DialogPrimitive.Description.displayName; + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogClose, + DialogTrigger, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +}; diff --git a/apps/public/src/components/ui/dropdown-menu.tsx b/apps/public/src/components/ui/dropdown-menu.tsx new file mode 100644 index 00000000..842e461c --- /dev/null +++ b/apps/public/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,199 @@ +'use client'; + +import * as React from 'react'; +import { cn } from '@/utils/cn'; +import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'; +import { Check, ChevronRight, Circle } from 'lucide-react'; + +const DropdownMenu = DropdownMenuPrimitive.Root; + +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; + +const DropdownMenuGroup = DropdownMenuPrimitive.Group; + +const DropdownMenuPortal = DropdownMenuPrimitive.Portal; + +const DropdownMenuSub = DropdownMenuPrimitive.Sub; + +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; + +const DropdownMenuSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)); +DropdownMenuSubTrigger.displayName = + DropdownMenuPrimitive.SubTrigger.displayName; + +const DropdownMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuSubContent.displayName = + DropdownMenuPrimitive.SubContent.displayName; + +const DropdownMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)); +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; + +const DropdownMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + +)); +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; + +const DropdownMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)); +DropdownMenuCheckboxItem.displayName = + DropdownMenuPrimitive.CheckboxItem.displayName; + +const DropdownMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)); +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName; + +const DropdownMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + +)); +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; + +const DropdownMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; + +const DropdownMenuShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ); +}; +DropdownMenuShortcut.displayName = 'DropdownMenuShortcut'; + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuGroup, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuRadioGroup, +}; diff --git a/apps/public/src/components/ui/input.tsx b/apps/public/src/components/ui/input.tsx new file mode 100644 index 00000000..3a6d2553 --- /dev/null +++ b/apps/public/src/components/ui/input.tsx @@ -0,0 +1,28 @@ +'use client'; + +import * as React from 'react'; +import { cn } from '@/utils/cn'; + +export type InputProps = React.InputHTMLAttributes & { + error?: string | undefined; +}; + +const Input = React.forwardRef( + ({ className, error, type, ...props }, ref) => { + return ( + + ); + } +); +Input.displayName = 'Input'; + +export { Input }; diff --git a/apps/public/src/components/ui/label.tsx b/apps/public/src/components/ui/label.tsx new file mode 100644 index 00000000..19859916 --- /dev/null +++ b/apps/public/src/components/ui/label.tsx @@ -0,0 +1,26 @@ +'use client'; + +import * as React from 'react'; +import { cn } from '@/utils/cn'; +import * as LabelPrimitive from '@radix-ui/react-label'; +import { cva } from 'class-variance-authority'; +import type { VariantProps } from 'class-variance-authority'; + +const labelVariants = cva( + 'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 block mb-2' +); + +const Label = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, ...props }, ref) => ( + +)); +Label.displayName = LabelPrimitive.Root.displayName; + +export { Label }; diff --git a/apps/public/src/components/ui/popover.tsx b/apps/public/src/components/ui/popover.tsx new file mode 100644 index 00000000..b86795cd --- /dev/null +++ b/apps/public/src/components/ui/popover.tsx @@ -0,0 +1,30 @@ +'use client'; + +import * as React from 'react'; +import { cn } from '@/utils/cn'; +import * as PopoverPrimitive from '@radix-ui/react-popover'; + +const Popover = PopoverPrimitive.Root; + +const PopoverTrigger = PopoverPrimitive.Trigger; + +const PopoverContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => ( + <> + + +)); +PopoverContent.displayName = PopoverPrimitive.Content.displayName; + +export { Popover, PopoverTrigger, PopoverContent }; diff --git a/apps/public/src/components/ui/radio-group.tsx b/apps/public/src/components/ui/radio-group.tsx new file mode 100644 index 00000000..fb251a99 --- /dev/null +++ b/apps/public/src/components/ui/radio-group.tsx @@ -0,0 +1,47 @@ +'use client'; + +import * as React from 'react'; +import { cn } from '@/utils/cn'; + +export type RadioGroupProps = React.InputHTMLAttributes; +export type RadioGroupItemProps = + React.InputHTMLAttributes & { + active?: boolean; + }; + +const RadioGroup = React.forwardRef( + ({ className, type, ...props }, ref) => { + return ( +
+ ); + } +); + +const RadioGroupItem = React.forwardRef( + ({ className, active, ...props }, ref) => { + return ( +