diff --git a/README.md b/README.md index 1b3b83f..6ccce62 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,15 @@ npm install npm run dev ``` +Inside another terminal session we can start the dashboard UI using npm: + +```bash +cd dashboard +cp .env.example .env +npm install +npm run dev +``` + ## Deployment *soon.* diff --git a/dashboard/.env.example b/dashboard/.env.example new file mode 100644 index 0000000..0890e33 --- /dev/null +++ b/dashboard/.env.example @@ -0,0 +1,8 @@ + +ORY_KRATOS_ADMIN_URL=http://localhost:4434 +ORY_HYDRA_ADMIN_URL=http://localhost:4445 + +NEXT_PUBLIC_ORY_KRATOS_URL=http://localhost:4433 + +NEXT_PUBLIC_AUTHENTICATION_NODE_URL=http://localhost:3000 +NEXT_PUBLIC_DASHBOARD_NODE_URL=http://localhost:4000 diff --git a/dashboard/.eslintrc.json b/dashboard/.eslintrc.json new file mode 100644 index 0000000..bffb357 --- /dev/null +++ b/dashboard/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": "next/core-web-vitals" +} diff --git a/dashboard/.gitignore b/dashboard/.gitignore new file mode 100644 index 0000000..16fb5e1 --- /dev/null +++ b/dashboard/.gitignore @@ -0,0 +1,40 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js +.yarn/install-state.gz + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env +.env*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +# serwist +public/sw.js diff --git a/dashboard/Dockerfile b/dashboard/Dockerfile new file mode 100644 index 0000000..fdd1557 --- /dev/null +++ b/dashboard/Dockerfile @@ -0,0 +1,49 @@ +FROM node:21-alpine AS base + +# Install dependencies only when needed +FROM base AS deps + +RUN apk add --no-cache libc6-compat +WORKDIR /app + +COPY package.json package-lock.json* ./ +RUN npm ci + + +# Rebuild the source code only when needed +FROM base AS builder + +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . + +ENV NEXT_TELEMETRY_DISABLED 1 +RUN npm run build + + +# Production image, copy all the files and run next +FROM base AS runner +WORKDIR /app + +ENV NODE_ENV production +ENV NEXT_TELEMETRY_DISABLED 1 + +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs + +COPY --from=builder /app/public ./public + +RUN mkdir .next +RUN chown nextjs:nodejs .next + +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +USER nextjs + +EXPOSE 3000 + +ENV PORT 3000 +ENV HOSTNAME "0.0.0.0" + +CMD ["node", "server.js"] diff --git a/dashboard/README.md b/dashboard/README.md new file mode 100644 index 0000000..1d8021d --- /dev/null +++ b/dashboard/README.md @@ -0,0 +1,9 @@ +# Next-Ory - Dashboard + +This directory contains a NextJS 15 (app router) UI Node, implementing the admin dashboard to the ORY Kratos instance. + +## Stack + +- [NextJS](https://nextjs.org/) +- [TailwindCSS](https://tailwindcss.com/) +- [shadcn/ui](https://ui.shadcn.com/) diff --git a/dashboard/bun.lockb b/dashboard/bun.lockb new file mode 100755 index 0000000..6d3fd3c Binary files /dev/null and b/dashboard/bun.lockb differ diff --git a/dashboard/components.json b/dashboard/components.json new file mode 100644 index 0000000..8ecaf9e --- /dev/null +++ b/dashboard/components.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "tailwind.config.ts", + "css": "src/app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils" + } +} diff --git a/dashboard/next.config.mjs b/dashboard/next.config.mjs new file mode 100644 index 0000000..7bfcddb --- /dev/null +++ b/dashboard/next.config.mjs @@ -0,0 +1,13 @@ +import withSerwistInit from '@serwist/next'; + +const withSerwist = withSerwistInit({ + swSrc: 'src/app/service-worker.ts', + swDest: 'public/sw.js', +}); + +export default withSerwist({ + output: 'standalone', + env: { + appVersion: process.env.npm_package_version, + }, +}); diff --git a/dashboard/package.json b/dashboard/package.json new file mode 100644 index 0000000..c16c4cb --- /dev/null +++ b/dashboard/package.json @@ -0,0 +1,59 @@ +{ + "name": "next-base-dashboard", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev -p 4000", + "build": "next build", + "start": "next start", + "lint": "next lint" + }, + "dependencies": { + "@hookform/resolvers": "^3.3.4", + "@ory/client": "^1.9.0", + "@ory/integrations": "^1.1.5", + "@radix-ui/react-alert-dialog": "^1.0.5", + "@radix-ui/react-checkbox": "^1.0.4", + "@radix-ui/react-dropdown-menu": "^2.0.6", + "@radix-ui/react-label": "^2.0.2", + "@radix-ui/react-scroll-area": "^1.0.5", + "@radix-ui/react-separator": "^1.0.3", + "@radix-ui/react-slot": "^1.0.2", + "@radix-ui/react-tabs": "^1.1.1", + "@serwist/next": "^9.0.0-preview.21", + "@serwist/precaching": "^9.0.0-preview.21", + "@serwist/sw": "^9.0.0-preview.21", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.0", + "lucide-react": "^0.460.0", + "next": "15.0.3", + "next-themes": "^0.4.3", + "oslo": "^1.1.3", + "react": "19.0.0-rc-66855b96-20241106", + "react-dom": "19.0.0-rc-66855b96-20241106", + "react-hook-form": "^7.51.0", + "sharp": "^0.33.4", + "sonner": "^1.4.3", + "tailwind-merge": "^2.2.1", + "tailwindcss-animate": "^1.0.7", + "ua-parser-js": "^2.0.0", + "usehooks-ts": "^3.1.0", + "zod": "^3.22.4" + }, + "devDependencies": { + "@types/node": "^22.9.3", + "@types/react": "npm:types-react@19.0.0-rc.1", + "@types/react-dom": "npm:types-react-dom@19.0.0-rc.1", + "autoprefixer": "^10.0.1", + "eslint": "^8", + "eslint-config-next": "15.0.3", + "postcss": "^8", + "tailwindcss": "^3.3.0", + "ts-node": "^10.9.2", + "typescript": "^5.4.2" + }, + "overrides": { + "@types/react": "npm:types-react@19.0.0-rc.1", + "@types/react-dom": "npm:types-react-dom@19.0.0-rc.1" + } +} diff --git a/dashboard/postcss.config.js b/dashboard/postcss.config.js new file mode 100644 index 0000000..67cdf1a --- /dev/null +++ b/dashboard/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/dashboard/public/favicon.png b/dashboard/public/favicon.png new file mode 100644 index 0000000..fdace3b Binary files /dev/null and b/dashboard/public/favicon.png differ diff --git a/dashboard/public/icon-128.png b/dashboard/public/icon-128.png new file mode 100644 index 0000000..0f5c25b Binary files /dev/null and b/dashboard/public/icon-128.png differ diff --git a/dashboard/public/icon-144.png b/dashboard/public/icon-144.png new file mode 100644 index 0000000..eb96684 Binary files /dev/null and b/dashboard/public/icon-144.png differ diff --git a/dashboard/public/icon-192.png b/dashboard/public/icon-192.png new file mode 100644 index 0000000..ba8cc78 Binary files /dev/null and b/dashboard/public/icon-192.png differ diff --git a/dashboard/public/icon-512.png b/dashboard/public/icon-512.png new file mode 100644 index 0000000..70de619 Binary files /dev/null and b/dashboard/public/icon-512.png differ diff --git a/dashboard/public/icon-72.png b/dashboard/public/icon-72.png new file mode 100644 index 0000000..48b11f6 Binary files /dev/null and b/dashboard/public/icon-72.png differ diff --git a/dashboard/public/manifest.json b/dashboard/public/manifest.json new file mode 100644 index 0000000..97abfd0 --- /dev/null +++ b/dashboard/public/manifest.json @@ -0,0 +1,37 @@ +{ + "name": "Next Base", + "short_name": "Next Base", + "description": "The starting point for your next project", + "icons": [ + { + "src": "icon-72.png", + "sizes": "72x72", + "type": "image/png" + }, + { + "src": "icon-128.png", + "sizes": "128x128", + "type": "image/png" + }, + { + "src": "icon-144.png", + "sizes": "144x144", + "type": "image/png" + }, + { + "src": "icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icon-512.png", + "sizes": "512x512", + "type": "image/png" + } + ], + "theme_color": "#0B0908", + "background_color": "#0B0908", + "start_url": "/", + "display": "standalone", + "orientation": "portrait" +} \ No newline at end of file diff --git a/dashboard/public/mt-logo-orange.png b/dashboard/public/mt-logo-orange.png new file mode 100644 index 0000000..57239fc Binary files /dev/null and b/dashboard/public/mt-logo-orange.png differ diff --git a/dashboard/src/app/globals.css b/dashboard/src/app/globals.css new file mode 100644 index 0000000..8827f47 --- /dev/null +++ b/dashboard/src/app/globals.css @@ -0,0 +1,62 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 20 14.3% 4.1%; + --card: 0 0% 100%; + --card-foreground: 20 14.3% 4.1%; + --popover: 0 0% 100%; + --popover-foreground: 20 14.3% 4.1%; + --primary: 24.6 95% 53.1%; + --primary-foreground: 60 9.1% 97.8%; + --secondary: 60 4.8% 95.9%; + --secondary-foreground: 24 9.8% 10%; + --muted: 60 4.8% 95.9%; + --muted-foreground: 25 5.3% 44.7%; + --accent: 60 4.8% 95.9%; + --accent-foreground: 24 9.8% 10%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 60 9.1% 97.8%; + --border: 20 5.9% 90%; + --input: 20 5.9% 90%; + --ring: 24.6 95% 53.1%; + --radius: 0.5rem; + } + + .dark { + --background: 20 14.3% 4.1%; + --foreground: 60 9.1% 97.8%; + --card: 20 14.3% 4.1%; + --card-foreground: 60 9.1% 97.8%; + --popover: 20 14.3% 4.1%; + --popover-foreground: 60 9.1% 97.8%; + --primary: 20.5 90.2% 48.2%; + --primary-foreground: 60 9.1% 97.8%; + --secondary: 12 6.5% 15.1%; + --secondary-foreground: 60 9.1% 97.8%; + --muted: 12 6.5% 15.1%; + --muted-foreground: 24 5.4% 63.9%; + --accent: 12 6.5% 15.1%; + --accent-foreground: 60 9.1% 97.8%; + --destructive: 0 72.2% 50.6%; + --destructive-foreground: 60 9.1% 97.8%; + --border: 12 6.5% 15.1%; + --input: 12 6.5% 15.1%; + --ring: 20.5 90.2% 48.2%; + } +} + + +@layer base { + * { + @apply border-border; + } + + body { + @apply bg-background text-foreground; + } +} \ No newline at end of file diff --git a/dashboard/src/app/layout.tsx b/dashboard/src/app/layout.tsx new file mode 100644 index 0000000..9b81615 --- /dev/null +++ b/dashboard/src/app/layout.tsx @@ -0,0 +1,63 @@ +import type { Viewport } from 'next'; +import { Inter } from 'next/font/google'; +import './globals.css'; +import { cn } from '@/lib/utils'; +import { Toaster } from '@/components/ui/sonner'; +import React from 'react'; +import { ThemeProvider } from 'next-themes'; + +const inter = Inter({ subsets: ['latin'] }); + +const APP_NAME = 'Next Ory'; +const APP_DEFAULT_TITLE = 'Next Ory'; +const APP_TITLE_TEMPLATE = `%s | ${APP_DEFAULT_TITLE}`; +const APP_DESCRIPTION = 'Get started with ORY authentication quickly and easily.'; + +export const metadata = { + applicationName: APP_NAME, + title: { + default: APP_DEFAULT_TITLE, + template: APP_TITLE_TEMPLATE, + }, + description: APP_DESCRIPTION, + appleWebApp: { + capable: true, + statusBarStyle: 'default', + title: APP_DEFAULT_TITLE, + }, + formatDetection: { + telephone: false, + }, +}; + +export const viewport: Viewport = { + themeColor: '#0B0908', + width: 'device-width', +}; + +export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) { + return ( + + + + + + +
+ + {children} + + +
+ + + ); +} diff --git a/dashboard/src/app/page.tsx b/dashboard/src/app/page.tsx new file mode 100644 index 0000000..8fbe383 --- /dev/null +++ b/dashboard/src/app/page.tsx @@ -0,0 +1,53 @@ +'use client'; + +import React, { useCallback, useEffect, useState } from 'react'; +import { kratos, LogoutLink } from '@/ory'; +import { useRouter } from 'next/navigation'; +import { ThemeToggle } from '@/components/themeToggle'; +import { Session } from '@ory/client'; +import { LogOut } from 'lucide-react'; +import { Button } from '@/components/ui/button'; + +export default function Page() { + + const router = useRouter(); + + const [session, setSession] = useState(); + const loadSession = useCallback(async () => { + console.log(kratos.toSession()); + return kratos.toSession(); + }, [router]); + + useEffect(() => { + + if (session) { + return; + } + + loadSession() + .then((response) => { + console.log(response.data); + response.data && setSession(response.data); + }) + .catch(() => { + const authentication_url = process.env.NEXT_PUBLIC_AUTHENTICATION_NODE_URL; + const dashboard_url = process.env.NEXT_PUBLIC_AUTHENTICATION_NODE_URL; + authentication_url && dashboard_url && + router.push(authentication_url + '/flow/login?return_to=' + dashboard_url); + }); + + }, [router, session]); + + const onLogout = LogoutLink(); + + return ( +
+
+ + +
+
+ ); +} diff --git a/dashboard/src/app/service-worker.ts b/dashboard/src/app/service-worker.ts new file mode 100644 index 0000000..0538ba3 --- /dev/null +++ b/dashboard/src/app/service-worker.ts @@ -0,0 +1,18 @@ +import type { PrecacheEntry } from '@serwist/precaching'; +import { installSerwist } from '@serwist/sw'; +import { defaultCache } from '@serwist/next/worker'; + +declare const self: ServiceWorkerGlobalScope & { + // Change this attribute's name to your `injectionPoint`. + // `injectionPoint` is an InjectManifest option. + // See https://serwist.pages.dev/docs/build/inject-manifest/configuring + __SW_MANIFEST: (PrecacheEntry | string)[] | undefined; +}; + +installSerwist({ + precacheEntries: self.__SW_MANIFEST, + skipWaiting: true, + clientsClaim: true, + navigationPreload: true, + runtimeCaching: defaultCache, +}); diff --git a/dashboard/src/components/themeProvider.tsx b/dashboard/src/components/themeProvider.tsx new file mode 100644 index 0000000..af4029a --- /dev/null +++ b/dashboard/src/components/themeProvider.tsx @@ -0,0 +1,8 @@ +'use client'; + +import * as React from 'react'; +import { ThemeProvider as NextThemesProvider, ThemeProviderProps } from 'next-themes'; + +export function ThemeProvider({ children, ...props }: ThemeProviderProps) { + return {children}; +} diff --git a/dashboard/src/components/themeToggle.tsx b/dashboard/src/components/themeToggle.tsx new file mode 100644 index 0000000..d8e00ae --- /dev/null +++ b/dashboard/src/components/themeToggle.tsx @@ -0,0 +1,43 @@ +'use client'; + +import * as React from 'react'; +import { Moon, Sun } from 'lucide-react'; +import { useTheme } from 'next-themes'; + +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; + +export function ThemeToggle() { + + const { setTheme } = useTheme(); + + return ( + + + + + + setTheme('light')}> + Light + + setTheme('dark')}> + Dark + + setTheme('system')}> + System + + + + ); +} diff --git a/dashboard/src/components/ui/alert-dialog.tsx b/dashboard/src/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..d65d9d1 --- /dev/null +++ b/dashboard/src/components/ui/alert-dialog.tsx @@ -0,0 +1,141 @@ +'use client'; + +import * as React from 'react'; +import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'; + +import { cn } from '@/lib/utils'; +import { buttonVariants } from '@/components/ui/button'; + +const AlertDialog = AlertDialogPrimitive.Root; + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger; + +const AlertDialogPortal = AlertDialogPrimitive.Portal; + +const AlertDialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...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/dashboard/src/components/ui/alert.tsx b/dashboard/src/components/ui/alert.tsx new file mode 100644 index 0000000..0d93e7c --- /dev/null +++ b/dashboard/src/components/ui/alert.tsx @@ -0,0 +1,59 @@ +import * as React from 'react'; +import { cva, type VariantProps } from 'class-variance-authority'; + +import { cn } from '@/lib/utils'; + +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/50 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/dashboard/src/components/ui/badge.tsx b/dashboard/src/components/ui/badge.tsx new file mode 100644 index 0000000..a59a355 --- /dev/null +++ b/dashboard/src/components/ui/badge.tsx @@ -0,0 +1,37 @@ +import * as React from 'react'; +import { cva, type VariantProps } from 'class-variance-authority'; + +import { cn } from '@/lib/utils'; + +const badgeVariants = cva( + 'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs 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', + 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/dashboard/src/components/ui/button.tsx b/dashboard/src/components/ui/button.tsx new file mode 100644 index 0000000..0ae7431 --- /dev/null +++ b/dashboard/src/components/ui/button.tsx @@ -0,0 +1,56 @@ +import * as React from 'react'; +import { Slot } from '@radix-ui/react-slot'; +import { cva, type VariantProps } from 'class-variance-authority'; + +import { cn } from '@/lib/utils'; + +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/dashboard/src/components/ui/card.tsx b/dashboard/src/components/ui/card.tsx new file mode 100644 index 0000000..3c69472 --- /dev/null +++ b/dashboard/src/components/ui/card.tsx @@ -0,0 +1,79 @@ +import * as React from 'react'; + +import { cn } from '@/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/dashboard/src/components/ui/checkbox.tsx b/dashboard/src/components/ui/checkbox.tsx new file mode 100644 index 0000000..96c99d1 --- /dev/null +++ b/dashboard/src/components/ui/checkbox.tsx @@ -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 '@/lib/utils'; + +const Checkbox = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + +)); +Checkbox.displayName = CheckboxPrimitive.Root.displayName; + +export { Checkbox }; diff --git a/dashboard/src/components/ui/dropdown-menu.tsx b/dashboard/src/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..f50a704 --- /dev/null +++ b/dashboard/src/components/ui/dropdown-menu.tsx @@ -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 '@/lib/utils'; + +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/dashboard/src/components/ui/form.tsx b/dashboard/src/components/ui/form.tsx new file mode 100644 index 0000000..6560780 --- /dev/null +++ b/dashboard/src/components/ui/form.tsx @@ -0,0 +1,169 @@ +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 '@/lib/utils'; +import { Label } from '@/components/ui/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 ( +