mirror of
https://github.com/Since-3/since3-portal.git
synced 2026-10-12 20:51:17 +00:00
new clients functionality
This commit is contained in:
parent
9dbacf4d45
commit
5edd697056
25 changed files with 1268 additions and 56 deletions
36
.github/workflows/ci.yml
vendored
36
.github/workflows/ci.yml
vendored
|
|
@ -1,36 +0,0 @@
|
||||||
name: CI
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
branches: [dev]
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
ci:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup Node
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 22.12.0
|
|
||||||
cache: npm
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: npm ci
|
|
||||||
|
|
||||||
- name: Lint
|
|
||||||
run: npm run lint
|
|
||||||
|
|
||||||
- name: Type check
|
|
||||||
run: npx tsc --noEmit
|
|
||||||
|
|
||||||
- name: Build
|
|
||||||
run: npm run build
|
|
||||||
env:
|
|
||||||
NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}
|
|
||||||
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY }}
|
|
||||||
SUPABASE_SECRET_KEY: ${{ secrets.SUPABASE_SECRET_KEY }}
|
|
||||||
29
app/admin/clients/[id]/layout.tsx
Normal file
29
app/admin/clients/[id]/layout.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { notFound, redirect } from "next/navigation";
|
||||||
|
import { createAdminClient } from "@/lib/supabase/admin";
|
||||||
|
import { verifySession } from "@/lib/supabase/dal";
|
||||||
|
|
||||||
|
type Params = Promise<{ id: string }>;
|
||||||
|
|
||||||
|
export default async function ClientDetailLayout({
|
||||||
|
children,
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
params: Params;
|
||||||
|
}) {
|
||||||
|
const session = await verifySession();
|
||||||
|
if (!session.isAdmin) redirect("/client/dashboard");
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const supabase = createAdminClient();
|
||||||
|
const { data: client } = await supabase
|
||||||
|
.from("profiles")
|
||||||
|
.select("id")
|
||||||
|
.eq("id", id)
|
||||||
|
.eq("role", "client")
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (!client) notFound();
|
||||||
|
|
||||||
|
return children;
|
||||||
|
}
|
||||||
26
app/admin/clients/[id]/page.tsx
Normal file
26
app/admin/clients/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { createAdminClient } from "@/lib/supabase/admin";
|
||||||
|
|
||||||
|
type Params = Promise<{ id: string }>;
|
||||||
|
|
||||||
|
const ClientDetailPage = async ({ params }: { params: Params }) => {
|
||||||
|
const { id } = await params;
|
||||||
|
const supabase = createAdminClient();
|
||||||
|
const { data: client } = await supabase
|
||||||
|
.from("profiles")
|
||||||
|
.select("id, name, email")
|
||||||
|
.eq("id", id)
|
||||||
|
.eq("role", "client")
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (!client) notFound();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<h1 className="text-3xl font-semibold">{client.name}</h1>
|
||||||
|
<p className="text-muted-foreground">{client.email}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ClientDetailPage
|
||||||
12
app/admin/clients/page.tsx
Normal file
12
app/admin/clients/page.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { verifySession } from "@/lib/supabase/dal"
|
||||||
|
import ClientListViewPage from "@/modules/admin/ui/views/client-list-view";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
const Clients = async () => {
|
||||||
|
const session = await verifySession();
|
||||||
|
if (!session.isAdmin) redirect("/client/dashboard");
|
||||||
|
|
||||||
|
return <ClientListViewPage/>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Clients
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Inter, JetBrains_Mono } from "next/font/google";
|
import { Inter, JetBrains_Mono } from "next/font/google";
|
||||||
|
import { ThemeProvider } from "@/components/theme-provider";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
const inter = Inter({
|
const inter = Inter({
|
||||||
|
|
@ -22,8 +23,11 @@ export default function RootLayout({ children }: LayoutProps<"/">) {
|
||||||
<html
|
<html
|
||||||
lang="en"
|
lang="en"
|
||||||
className={`${inter.variable} ${jetbrainsMono.variable} h-full antialiased`}
|
className={`${inter.variable} ${jetbrainsMono.variable} h-full antialiased`}
|
||||||
|
suppressHydrationWarning
|
||||||
>
|
>
|
||||||
<body className="min-h-full flex flex-col">{children}</body>
|
<body className="min-h-full flex flex-col">
|
||||||
|
<ThemeProvider>{children}</ThemeProvider>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
15
components/theme-provider.tsx
Normal file
15
components/theme-provider.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { ThemeProvider as NextThemesProvider } from "next-themes"
|
||||||
|
|
||||||
|
export function ThemeProvider({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<NextThemesProvider attribute="class" defaultTheme="system" enableSystem>
|
||||||
|
{children}
|
||||||
|
</NextThemesProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -3,11 +3,11 @@ import { cva, type VariantProps } from "class-variance-authority"
|
||||||
import { cn } from "cn"
|
import { cn } from "cn"
|
||||||
|
|
||||||
const buttonVariants = cva(
|
const buttonVariants = cva(
|
||||||
"group/button inline-flex shrink-0 items-center justify-center gap-2 rounded-md border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-[background-color,border-color,color] duration-[var(--duration-fast)] ease-out outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
"group/button inline-flex shrink-0 items-center justify-center gap-2 cursor-pointer rounded-md border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-[background-color,border-color,color] duration-[var(--duration-fast)] ease-out outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
primary: "bg-primary text-primary-foreground hover:bg-accent-600",
|
primary: "bg-primary text-primary-foreground hover:bg-accent-600 dark:hover:bg-ink-100",
|
||||||
secondary:
|
secondary:
|
||||||
"bg-secondary text-secondary-foreground border-border hover:bg-ink-100 dark:hover:bg-ink-800",
|
"bg-secondary text-secondary-foreground border-border hover:bg-ink-100 dark:hover:bg-ink-800",
|
||||||
outline:
|
outline:
|
||||||
|
|
|
||||||
21
components/ui/collapsible.tsx
Normal file
21
components/ui/collapsible.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Collapsible as CollapsiblePrimitive } from "@base-ui/react/collapsible"
|
||||||
|
|
||||||
|
function Collapsible({ ...props }: CollapsiblePrimitive.Root.Props) {
|
||||||
|
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function CollapsibleTrigger({ ...props }: CollapsiblePrimitive.Trigger.Props) {
|
||||||
|
return (
|
||||||
|
<CollapsiblePrimitive.Trigger data-slot="collapsible-trigger" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CollapsibleContent({ ...props }: CollapsiblePrimitive.Panel.Props) {
|
||||||
|
return (
|
||||||
|
<CollapsiblePrimitive.Panel data-slot="collapsible-content" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||||
160
components/ui/dialog.tsx
Normal file
160
components/ui/dialog.tsx
Normal file
|
|
@ -0,0 +1,160 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
|
||||||
|
import { cn } from "cn"
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { XIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
|
||||||
|
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
|
||||||
|
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
|
||||||
|
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
|
||||||
|
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: DialogPrimitive.Backdrop.Props) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Backdrop
|
||||||
|
data-slot="dialog-overlay"
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
showCloseButton = true,
|
||||||
|
...props
|
||||||
|
}: DialogPrimitive.Popup.Props & {
|
||||||
|
showCloseButton?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Popup
|
||||||
|
data-slot="dialog-content"
|
||||||
|
className={cn(
|
||||||
|
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<DialogPrimitive.Close
|
||||||
|
data-slot="dialog-close"
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="absolute top-2 right-2"
|
||||||
|
size="icon-sm"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<XIcon
|
||||||
|
/>
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</DialogPrimitive.Popup>
|
||||||
|
</DialogPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-header"
|
||||||
|
className={cn("flex flex-col gap-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogFooter({
|
||||||
|
className,
|
||||||
|
showCloseButton = false,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
showCloseButton?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-footer"
|
||||||
|
className={cn(
|
||||||
|
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<DialogPrimitive.Close render={<Button variant="outline" />}>
|
||||||
|
Close
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
data-slot="dialog-title"
|
||||||
|
className={cn(
|
||||||
|
"font-heading text-base leading-none font-medium",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: DialogPrimitive.Description.Props) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
data-slot="dialog-description"
|
||||||
|
className={cn(
|
||||||
|
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogPortal,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
}
|
||||||
267
components/ui/dropdown-menu.tsx
Normal file
267
components/ui/dropdown-menu.tsx
Normal file
|
|
@ -0,0 +1,267 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
|
||||||
|
import { cn } from "cn"
|
||||||
|
import { ChevronRightIcon, CheckIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
|
||||||
|
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
|
||||||
|
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
|
||||||
|
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuContent({
|
||||||
|
align = "start",
|
||||||
|
alignOffset = 0,
|
||||||
|
side = "bottom",
|
||||||
|
sideOffset = 4,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: MenuPrimitive.Popup.Props &
|
||||||
|
Pick<
|
||||||
|
MenuPrimitive.Positioner.Props,
|
||||||
|
"align" | "alignOffset" | "side" | "sideOffset"
|
||||||
|
>) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.Portal>
|
||||||
|
<MenuPrimitive.Positioner
|
||||||
|
className="isolate z-50 outline-none"
|
||||||
|
align={align}
|
||||||
|
alignOffset={alignOffset}
|
||||||
|
side={side}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
>
|
||||||
|
<MenuPrimitive.Popup
|
||||||
|
data-slot="dropdown-menu-content"
|
||||||
|
className={cn("z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</MenuPrimitive.Positioner>
|
||||||
|
</MenuPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
|
||||||
|
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuLabel({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: MenuPrimitive.GroupLabel.Props & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.GroupLabel
|
||||||
|
data-slot="dropdown-menu-label"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuItem({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
variant = "default",
|
||||||
|
...props
|
||||||
|
}: MenuPrimitive.Item.Props & {
|
||||||
|
inset?: boolean
|
||||||
|
variant?: "default" | "destructive"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.Item
|
||||||
|
data-slot="dropdown-menu-item"
|
||||||
|
data-inset={inset}
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(
|
||||||
|
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
|
||||||
|
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSubTrigger({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: MenuPrimitive.SubmenuTrigger.Props & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.SubmenuTrigger
|
||||||
|
data-slot="dropdown-menu-sub-trigger"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRightIcon className="ml-auto" />
|
||||||
|
</MenuPrimitive.SubmenuTrigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSubContent({
|
||||||
|
align = "start",
|
||||||
|
alignOffset = -3,
|
||||||
|
side = "right",
|
||||||
|
sideOffset = 0,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuContent>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuContent
|
||||||
|
data-slot="dropdown-menu-sub-content"
|
||||||
|
className={cn("w-auto min-w-[96px] rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||||
|
align={align}
|
||||||
|
alignOffset={alignOffset}
|
||||||
|
side={side}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuCheckboxItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
checked,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: MenuPrimitive.CheckboxItem.Props & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.CheckboxItem
|
||||||
|
data-slot="dropdown-menu-checkbox-item"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="pointer-events-none absolute right-2 flex items-center justify-center"
|
||||||
|
data-slot="dropdown-menu-checkbox-item-indicator"
|
||||||
|
>
|
||||||
|
<MenuPrimitive.CheckboxItemIndicator>
|
||||||
|
<CheckIcon
|
||||||
|
/>
|
||||||
|
</MenuPrimitive.CheckboxItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</MenuPrimitive.CheckboxItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.RadioGroup
|
||||||
|
data-slot="dropdown-menu-radio-group"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuRadioItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: MenuPrimitive.RadioItem.Props & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.RadioItem
|
||||||
|
data-slot="dropdown-menu-radio-item"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="pointer-events-none absolute right-2 flex items-center justify-center"
|
||||||
|
data-slot="dropdown-menu-radio-item-indicator"
|
||||||
|
>
|
||||||
|
<MenuPrimitive.RadioItemIndicator>
|
||||||
|
<CheckIcon
|
||||||
|
/>
|
||||||
|
</MenuPrimitive.RadioItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</MenuPrimitive.RadioItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: MenuPrimitive.Separator.Props) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.Separator
|
||||||
|
data-slot="dropdown-menu-separator"
|
||||||
|
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuShortcut({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="dropdown-menu-shortcut"
|
||||||
|
className={cn(
|
||||||
|
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuShortcut,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
}
|
||||||
158
components/ui/input-group.tsx
Normal file
158
components/ui/input-group.tsx
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
import { cn } from "cn"
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
|
||||||
|
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="input-group"
|
||||||
|
role="group"
|
||||||
|
className={cn(
|
||||||
|
"group/input-group relative flex h-8 w-full min-w-0 items-center rounded-lg border border-input transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:bg-input/50 has-disabled:opacity-50 has-[[data-slot=input-group-control] has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputGroupAddonVariants = cva(
|
||||||
|
"flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
align: {
|
||||||
|
"inline-start":
|
||||||
|
"order-first pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem]",
|
||||||
|
"inline-end":
|
||||||
|
"order-last pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem]",
|
||||||
|
"block-start":
|
||||||
|
"order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2",
|
||||||
|
"block-end":
|
||||||
|
"order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
align: "inline-start",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function InputGroupAddon({
|
||||||
|
className,
|
||||||
|
align = "inline-start",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
data-slot="input-group-addon"
|
||||||
|
data-align={align}
|
||||||
|
className={cn(inputGroupAddonVariants({ align }), className)}
|
||||||
|
onClick={(e) => {
|
||||||
|
if ((e.target as HTMLElement).closest("button")) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e.currentTarget.parentElement?.querySelector("input")?.focus()
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputGroupButtonVariants = cva(
|
||||||
|
"flex items-center gap-2 text-sm shadow-none",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
size: {
|
||||||
|
xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
|
||||||
|
sm: "",
|
||||||
|
"icon-xs":
|
||||||
|
"size-6 rounded-[calc(var(--radius)-3px)] p-0 has-[>svg]:p-0",
|
||||||
|
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
size: "xs",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function InputGroupButton({
|
||||||
|
className,
|
||||||
|
type = "button",
|
||||||
|
variant = "ghost",
|
||||||
|
size = "xs",
|
||||||
|
...props
|
||||||
|
}: Omit<React.ComponentProps<typeof Button>, "size" | "type"> &
|
||||||
|
VariantProps<typeof inputGroupButtonVariants> & {
|
||||||
|
type?: "button" | "submit" | "reset"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
type={type}
|
||||||
|
data-size={size}
|
||||||
|
variant={variant}
|
||||||
|
className={cn(inputGroupButtonVariants({ size }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 text-sm text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputGroupInput({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"input">) {
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
data-slot="input-group-control"
|
||||||
|
className={cn(
|
||||||
|
"flex-1 rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputGroupTextarea({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"textarea">) {
|
||||||
|
return (
|
||||||
|
<Textarea
|
||||||
|
data-slot="input-group-control"
|
||||||
|
className={cn(
|
||||||
|
"flex-1 resize-none rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
InputGroup,
|
||||||
|
InputGroupAddon,
|
||||||
|
InputGroupButton,
|
||||||
|
InputGroupText,
|
||||||
|
InputGroupInput,
|
||||||
|
InputGroupTextarea,
|
||||||
|
}
|
||||||
17
components/ui/textarea.tsx
Normal file
17
components/ui/textarea.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import { cn } from "cn"
|
||||||
|
|
||||||
|
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
data-slot="textarea"
|
||||||
|
className={cn(
|
||||||
|
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Textarea }
|
||||||
29
lib/generate-password.ts
Normal file
29
lib/generate-password.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { randomInt } from "crypto"
|
||||||
|
|
||||||
|
const LOWERCASE = "abcdefghijkmnpqrstuvwxyz"
|
||||||
|
const UPPERCASE = "ABCDEFGHJKLMNPQRSTUVWXYZ"
|
||||||
|
const DIGITS = "23456789"
|
||||||
|
const SYMBOLS = "!@#$%&*"
|
||||||
|
const ALL_CHARS = LOWERCASE + UPPERCASE + DIGITS + SYMBOLS
|
||||||
|
|
||||||
|
export function generatePassword(length = 14) {
|
||||||
|
const required = [
|
||||||
|
LOWERCASE[randomInt(LOWERCASE.length)],
|
||||||
|
UPPERCASE[randomInt(UPPERCASE.length)],
|
||||||
|
DIGITS[randomInt(DIGITS.length)],
|
||||||
|
SYMBOLS[randomInt(SYMBOLS.length)],
|
||||||
|
]
|
||||||
|
|
||||||
|
const rest = Array.from({ length: length - required.length }, () =>
|
||||||
|
ALL_CHARS[randomInt(ALL_CHARS.length)]
|
||||||
|
)
|
||||||
|
|
||||||
|
const password = [...required, ...rest]
|
||||||
|
|
||||||
|
for (let i = password.length - 1; i > 0; i--) {
|
||||||
|
const j = randomInt(i + 1)
|
||||||
|
;[password[i], password[j]] = [password[j], password[i]]
|
||||||
|
}
|
||||||
|
|
||||||
|
return password.join("")
|
||||||
|
}
|
||||||
50
modules/admin/actions/client.ts
Normal file
50
modules/admin/actions/client.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
"use server";
|
||||||
|
import { createAdminClient } from "@/lib/supabase/admin";
|
||||||
|
import { generatePassword } from "@/lib/generate-password";
|
||||||
|
import * as z from "zod";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const CreateClientSchema = z.object({
|
||||||
|
name: z.string().min(3, {error: "Mindestens drei Zeichen eingeben"}),
|
||||||
|
email: z.email({ error: "Bitte eine gültige Email eingeben." })
|
||||||
|
})
|
||||||
|
|
||||||
|
export type CreateClientState =
|
||||||
|
| { error: string }
|
||||||
|
| { success: true; email: string; password: string }
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
export async function createClient(
|
||||||
|
_prevState: CreateClientState,
|
||||||
|
formData: FormData
|
||||||
|
): Promise<CreateClientState> {
|
||||||
|
|
||||||
|
const validatedFields = CreateClientSchema.safeParse({
|
||||||
|
name: formData.get("name"),
|
||||||
|
email: formData.get("email")
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!validatedFields.success) {
|
||||||
|
const { fieldErrors } = z.flattenError(validatedFields.error);
|
||||||
|
const message = fieldErrors.email?.[0] ?? fieldErrors.name?.[0] ?? "Bitte Name und Email ausfüllen.";
|
||||||
|
return { error: message }
|
||||||
|
}
|
||||||
|
|
||||||
|
const { name, email } = validatedFields.data;
|
||||||
|
const password = generatePassword();
|
||||||
|
|
||||||
|
const supabase = createAdminClient();
|
||||||
|
const { error } = await supabase.auth.admin.createUser({
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
email_confirm: true,
|
||||||
|
user_metadata: { name },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return { error: error.code === "email_exists" ? "Diese Email ist bereits vergeben." : "Client konnte nicht angelegt werden." }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, email, password }
|
||||||
|
}
|
||||||
15
modules/admin/queries/clients.ts
Normal file
15
modules/admin/queries/clients.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import "server-only"
|
||||||
|
import { createAdminClient } from "@/lib/supabase/admin"
|
||||||
|
|
||||||
|
export async function getClients() {
|
||||||
|
const supabase = createAdminClient()
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from("profiles")
|
||||||
|
.select("id, name, email")
|
||||||
|
.eq("role", "client")
|
||||||
|
.order("name")
|
||||||
|
|
||||||
|
if (error) return []
|
||||||
|
|
||||||
|
return data
|
||||||
|
}
|
||||||
87
modules/admin/ui/components/add-client-dialog.tsx
Normal file
87
modules/admin/ui/components/add-client-dialog.tsx
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useActionState, useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, DialogDescription } from "@/components/ui/dialog";
|
||||||
|
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Mail, PlusIcon, UserRound } from "lucide-react";
|
||||||
|
import { createClient } from "@/modules/admin/actions/client";
|
||||||
|
import { CopyField } from "./copy-field";
|
||||||
|
|
||||||
|
function AddClientDialogContent({ onDone }: { onDone: () => void }) {
|
||||||
|
const [state, formAction, pending] = useActionState(createClient, undefined);
|
||||||
|
const success = state && "success" in state && state.success;
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DialogHeader className="mb-4">
|
||||||
|
<DialogTitle>Client angelegt 🎉</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Gib die Zugangsdaten an den Client weiter. Das Passwort wird nur jetzt angezeigt.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<FieldGroup>
|
||||||
|
<CopyField label="Email" value={state.email} />
|
||||||
|
<CopyField label="Passwort" value={state.password} />
|
||||||
|
</FieldGroup>
|
||||||
|
<DialogFooter className="mt-6">
|
||||||
|
<Button type="button" onClick={onDone}>Fertig</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DialogHeader className="mb-4">
|
||||||
|
<DialogTitle>Neuen Clienten anlegen</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form action={formAction}>
|
||||||
|
<FieldGroup>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="client-name"><UserRound className="w-4 h-4"/> Name <span className="text-destructive">*</span></FieldLabel>
|
||||||
|
<Input id="client-name" name="name" required className="focus-visible:ring-0"/>
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="client-email"><Mail className="w-4 h-4"/> Email <span className="text-destructive">*</span></FieldLabel>
|
||||||
|
<Input id="client-email" name="email" type="email" required className="focus-visible:ring-0"/>
|
||||||
|
</Field>
|
||||||
|
{state && "error" in state && (
|
||||||
|
<p className="text-sm text-destructive">{state.error}</p>
|
||||||
|
)}
|
||||||
|
</FieldGroup>
|
||||||
|
<DialogFooter className="mt-6">
|
||||||
|
<Button type="submit" disabled={pending}>
|
||||||
|
{pending ? "Wird angelegt..." : "Anlegen"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AddClientDialog() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [instanceKey, setInstanceKey] = useState(0);
|
||||||
|
|
||||||
|
function handleOpenChange(nextOpen: boolean) {
|
||||||
|
setOpen(nextOpen);
|
||||||
|
if (!nextOpen) {
|
||||||
|
setInstanceKey((key) => key + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
|
<DialogTrigger render={<Button className="cursor-pointer"/>}>
|
||||||
|
<PlusIcon/>
|
||||||
|
Add a new Client
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<AddClientDialogContent key={instanceKey} onDone={() => handleOpenChange(false)} />
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
123
modules/admin/ui/components/clients-table.tsx
Normal file
123
modules/admin/ui/components/clients-table.tsx
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { ArrowRightIcon, Building2Icon, Search } from "lucide-react";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group";
|
||||||
|
import { AddClientDialog } from "./add-client-dialog";
|
||||||
|
|
||||||
|
type Client = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_CONFIG = {
|
||||||
|
current: { label: "Current", variant: "warning" as const },
|
||||||
|
done: { label: "Done", variant: "success" as const },
|
||||||
|
upcoming: { label: "Upcoming", variant: "neutral" as const },
|
||||||
|
};
|
||||||
|
|
||||||
|
// TODO: Status kommt aktuell nicht aus der DB, sobald Projekte existieren durch echten Wert ersetzen.
|
||||||
|
function getPlaceholderStatus(index: number): keyof typeof STATUS_CONFIG {
|
||||||
|
const statuses: (keyof typeof STATUS_CONFIG)[] = ["current", "done", "upcoming"];
|
||||||
|
return statuses[index % statuses.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClientsTable({ clients }: { clients: Client[] }) {
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
|
||||||
|
const filteredClients = useMemo(() => {
|
||||||
|
const normalizedQuery = query.trim().toLowerCase();
|
||||||
|
if (!normalizedQuery) return clients;
|
||||||
|
|
||||||
|
return clients.filter(
|
||||||
|
(client) =>
|
||||||
|
client.name.toLowerCase().includes(normalizedQuery) ||
|
||||||
|
client.email.toLowerCase().includes(normalizedQuery)
|
||||||
|
);
|
||||||
|
}, [clients, query]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-8 w-full">
|
||||||
|
<div className="flex flex-row items-center justify-between gap-2 w-full p-2">
|
||||||
|
<InputGroup className="max-w-lg bg-white rounded-md focus-visible:ring-0">
|
||||||
|
<InputGroupInput
|
||||||
|
placeholder="Search..."
|
||||||
|
value={query}
|
||||||
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
|
/>
|
||||||
|
<InputGroupAddon>
|
||||||
|
<Search />
|
||||||
|
</InputGroupAddon>
|
||||||
|
</InputGroup>
|
||||||
|
<AddClientDialog />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filteredClients.length === 0 ? (
|
||||||
|
<p className="p-8 text-center text-muted-foreground">
|
||||||
|
{clients.length === 0 ? "Noch keine Clients vorhanden." : "Keine Clients gefunden."}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Client</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead className="w-16" />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{filteredClients.map((client) => {
|
||||||
|
const index = clients.indexOf(client);
|
||||||
|
const status = STATUS_CONFIG[getPlaceholderStatus(index)];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow key={client.id}>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Avatar size="lg" className="shrink-0">
|
||||||
|
<AvatarFallback>
|
||||||
|
<Building2Icon className="size-4.5 text-muted-foreground" />
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="flex min-w-0 flex-col">
|
||||||
|
<span className="truncate font-medium">{client.name}</span>
|
||||||
|
<span className="truncate text-small text-muted-foreground">{client.email}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={status.variant}>{status.label}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
nativeButton={false}
|
||||||
|
render={<Link href={`/admin/clients/${client.id}`} />}
|
||||||
|
aria-label={`${client.name} öffnen`}
|
||||||
|
>
|
||||||
|
<ArrowRightIcon />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
36
modules/admin/ui/components/copy-field.tsx
Normal file
36
modules/admin/ui/components/copy-field.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { CheckIcon, CopyIcon } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Field, FieldLabel } from "@/components/ui/field";
|
||||||
|
|
||||||
|
export function CopyField({ label, value }: { label: string; value: string }) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
async function handleCopy() {
|
||||||
|
await navigator.clipboard.writeText(value);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Field>
|
||||||
|
<FieldLabel>{label}</FieldLabel>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input value={value} readOnly className="focus-visible:ring-0" />
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="shrink-0 cursor-pointer"
|
||||||
|
onClick={handleCopy}
|
||||||
|
aria-label={`${label} kopieren`}
|
||||||
|
>
|
||||||
|
{copied ? <CheckIcon className="text-signal-success" /> : <CopyIcon />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
)
|
||||||
|
}
|
||||||
15
modules/admin/ui/views/client-list-view.tsx
Normal file
15
modules/admin/ui/views/client-list-view.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { ClientsTable } from "../components/clients-table"
|
||||||
|
import { getClients } from "../../queries/clients"
|
||||||
|
|
||||||
|
const ClientListViewPage = async () => {
|
||||||
|
const clients = await getClients()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-8 w-full">
|
||||||
|
<h1 className="text-3xl font-semibold">Clients</h1>
|
||||||
|
<ClientsTable clients={clients} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ClientListViewPage
|
||||||
|
|
@ -1,13 +1,14 @@
|
||||||
import {
|
import {
|
||||||
FileTextIcon,
|
FileTextIcon,
|
||||||
FoldersIcon,
|
FoldersIcon,
|
||||||
|
InfoIcon,
|
||||||
KeyIcon,
|
KeyIcon,
|
||||||
LayoutDashboardIcon,
|
LayoutDashboardIcon,
|
||||||
LucideIcon,
|
LucideIcon,
|
||||||
SettingsIcon,
|
SettingsIcon,
|
||||||
UserRoundIcon,
|
UserRoundIcon,
|
||||||
UsersRoundIcon
|
UsersRoundIcon
|
||||||
}
|
}
|
||||||
from "lucide-react";
|
from "lucide-react";
|
||||||
|
|
||||||
export type MenuItem = {
|
export type MenuItem = {
|
||||||
|
|
@ -60,4 +61,36 @@ export const clientMenu: MenuItem[] = [
|
||||||
url: "/client/storage",
|
url: "/client/storage",
|
||||||
icon: FoldersIcon
|
icon: FoldersIcon
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
export function getClientDetailMenu(clientId: string): MenuItem[] {
|
||||||
|
const base = `/admin/clients/${clientId}`
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
title: "Info",
|
||||||
|
url: base,
|
||||||
|
icon: InfoIcon
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Stammdaten",
|
||||||
|
url: `${base}/data`,
|
||||||
|
icon: UserRoundIcon
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Rechnungen",
|
||||||
|
url: `${base}/billing`,
|
||||||
|
icon: FileTextIcon
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Passwörter",
|
||||||
|
url: `${base}/passwords`,
|
||||||
|
icon: KeyIcon
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Dokumente",
|
||||||
|
url: `${base}/storage`,
|
||||||
|
icon: FoldersIcon
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -11,8 +11,10 @@ import {
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
import { Session } from "../../types/user";
|
import { Session } from "../../types/user";
|
||||||
import { adminMenu, clientMenu } from "../../data/sidebar-menu-content";
|
import { adminMenu, clientMenu } from "../../data/sidebar-menu-content";
|
||||||
|
import { ClientsMenuItem } from "./clients-menu-item";
|
||||||
import { LogoutButton } from "./logout-button";
|
import { LogoutButton } from "./logout-button";
|
||||||
import { SidebarMenuLink } from "./sidebar-menu-link";
|
import { SidebarMenuLink } from "./sidebar-menu-link";
|
||||||
|
import { ThemeToggle } from "./theme-toggle";
|
||||||
import UserBadge from "./user-badge";
|
import UserBadge from "./user-badge";
|
||||||
|
|
||||||
export function AppSidebar({ session }: { session: Session }) {
|
export function AppSidebar({ session }: { session: Session }) {
|
||||||
|
|
@ -37,21 +39,28 @@ export function AppSidebar({ session }: { session: Session }) {
|
||||||
<SidebarGroup>
|
<SidebarGroup>
|
||||||
<SidebarGroupContent>
|
<SidebarGroupContent>
|
||||||
<SidebarMenu className="gap-1.5">
|
<SidebarMenu className="gap-1.5">
|
||||||
{menu.map((item) => (
|
{menu.map((item) =>
|
||||||
<SidebarMenuLink
|
item.url === "/admin/clients" ? (
|
||||||
key={item.url}
|
<ClientsMenuItem key={item.url} />
|
||||||
title={item.title}
|
) : (
|
||||||
url={item.url}
|
<SidebarMenuLink
|
||||||
icon={<item.icon className="size-4.5" />}
|
key={item.url}
|
||||||
/>
|
title={item.title}
|
||||||
))}
|
url={item.url}
|
||||||
|
icon={<item.icon className="size-4.5" />}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
</SidebarGroupContent>
|
</SidebarGroupContent>
|
||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
<SidebarFooter className="gap-3 p-4">
|
<SidebarFooter className="gap-3 p-4">
|
||||||
<p className="truncate text-xs text-muted-foreground">{session.email}</p>
|
<p className="truncate text-xs text-muted-foreground">{session.email}</p>
|
||||||
<LogoutButton />
|
<div className="flex items-center gap-2">
|
||||||
|
<LogoutButton />
|
||||||
|
<ThemeToggle />
|
||||||
|
</div>
|
||||||
</SidebarFooter>
|
</SidebarFooter>
|
||||||
<SidebarRail />
|
<SidebarRail />
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
|
|
|
||||||
92
modules/shared/ui/components/clients-menu-item.tsx
Normal file
92
modules/shared/ui/components/clients-menu-item.tsx
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { ChevronRightIcon, UsersRoundIcon } from "lucide-react";
|
||||||
|
import {
|
||||||
|
Collapsible,
|
||||||
|
CollapsibleContent,
|
||||||
|
CollapsibleTrigger,
|
||||||
|
} from "@/components/ui/collapsible";
|
||||||
|
import {
|
||||||
|
SidebarMenuButton,
|
||||||
|
SidebarMenuItem,
|
||||||
|
SidebarMenuSub,
|
||||||
|
SidebarMenuSubButton,
|
||||||
|
SidebarMenuSubItem,
|
||||||
|
useSidebar,
|
||||||
|
} from "@/components/ui/sidebar";
|
||||||
|
import { getClientDetailMenu } from "@/modules/shared/data/sidebar-menu-content";
|
||||||
|
|
||||||
|
export function ClientsMenuItem() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const { setOpenMobile } = useSidebar();
|
||||||
|
|
||||||
|
const detailMatch = pathname.match(/^\/admin\/clients\/([^/]+)/);
|
||||||
|
const clientId = detailMatch?.[1];
|
||||||
|
|
||||||
|
if (!clientId) {
|
||||||
|
const isActive = pathname === "/admin/clients";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<SidebarMenuButton
|
||||||
|
render={<Link href="/admin/clients" onClick={() => setOpenMobile(false)} />}
|
||||||
|
isActive={isActive}
|
||||||
|
className="h-10 text-[0.95rem]"
|
||||||
|
>
|
||||||
|
<UsersRoundIcon className="size-4.5" />
|
||||||
|
<span>Clients</span>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const menu = getClientDetailMenu(clientId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Collapsible defaultOpen>
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<div className="relative">
|
||||||
|
<SidebarMenuButton
|
||||||
|
render={<Link href="/admin/clients" onClick={() => setOpenMobile(false)} />}
|
||||||
|
className="h-10 pr-9 text-[0.95rem]"
|
||||||
|
>
|
||||||
|
<UsersRoundIcon className="size-4.5" />
|
||||||
|
<span>Clients</span>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
<CollapsibleTrigger
|
||||||
|
render={
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Client-Menü umschalten"
|
||||||
|
className="group absolute top-1/2 right-1.5 -translate-y-1/2 rounded-md p-1.5 hover:bg-sidebar-accent"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ChevronRightIcon className="size-4 transition-transform group-data-panel-open:rotate-90" />
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
</div>
|
||||||
|
<CollapsibleContent>
|
||||||
|
<SidebarMenuSub>
|
||||||
|
{menu.map((item) => {
|
||||||
|
const isActive = pathname === item.url;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarMenuSubItem key={item.url}>
|
||||||
|
<SidebarMenuSubButton
|
||||||
|
render={<Link href={item.url} onClick={() => setOpenMobile(false)} />}
|
||||||
|
isActive={isActive}
|
||||||
|
>
|
||||||
|
<item.icon className="size-4" />
|
||||||
|
<span>{item.title}</span>
|
||||||
|
</SidebarMenuSubButton>
|
||||||
|
</SidebarMenuSubItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</SidebarMenuSub>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
</Collapsible>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
modules/shared/ui/components/theme-toggle.tsx
Normal file
38
modules/shared/ui/components/theme-toggle.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useSyncExternalStore } from "react";
|
||||||
|
import { useTheme } from "next-themes";
|
||||||
|
import { MoonIcon, SunIcon } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
function subscribe() {
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function useMounted() {
|
||||||
|
return useSyncExternalStore(subscribe, () => true, () => false);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ThemeToggle() {
|
||||||
|
const { resolvedTheme, setTheme } = useTheme();
|
||||||
|
const mounted = useMounted();
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return <Button variant="outline" size="icon" className="shrink-0" disabled />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDark = resolvedTheme === "dark";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="shrink-0 cursor-pointer"
|
||||||
|
onClick={() => setTheme(isDark ? "light" : "dark")}
|
||||||
|
aria-label={isDark ? "Zu hellem Modus wechseln" : "Zu dunklem Modus wechseln"}
|
||||||
|
>
|
||||||
|
{isDark ? <SunIcon /> : <MoonIcon />}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
11
package-lock.json
generated
11
package-lock.json
generated
|
|
@ -15,6 +15,7 @@
|
||||||
"cn": "^0.4.0",
|
"cn": "^0.4.0",
|
||||||
"lucide-react": "^1.48.0",
|
"lucide-react": "^1.48.0",
|
||||||
"next": "16.3.6",
|
"next": "16.3.6",
|
||||||
|
"next-themes": "^0.4.6",
|
||||||
"react": "19.2.8",
|
"react": "19.2.8",
|
||||||
"react-dom": "19.2.8",
|
"react-dom": "19.2.8",
|
||||||
"shadcn": "^4.21.0",
|
"shadcn": "^4.21.0",
|
||||||
|
|
@ -5945,6 +5946,16 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/next-themes": {
|
||||||
|
"version": "0.4.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz",
|
||||||
|
"integrity": "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/next/node_modules/postcss": {
|
"node_modules/next/node_modules/postcss": {
|
||||||
"version": "8.5.23",
|
"version": "8.5.23",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz",
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@
|
||||||
"cn": "^0.4.0",
|
"cn": "^0.4.0",
|
||||||
"lucide-react": "^1.48.0",
|
"lucide-react": "^1.48.0",
|
||||||
"next": "16.3.6",
|
"next": "16.3.6",
|
||||||
|
"next-themes": "^0.4.6",
|
||||||
"react": "19.2.8",
|
"react": "19.2.8",
|
||||||
"react-dom": "19.2.8",
|
"react-dom": "19.2.8",
|
||||||
"shadcn": "^4.21.0",
|
"shadcn": "^4.21.0",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue