diff --git a/.bruno/Payment/beneficiary/dashboard.bru b/.bruno/Payment/beneficiary/dashboard.bru index 467af1f..84ff776 100644 --- a/.bruno/Payment/beneficiary/dashboard.bru +++ b/.bruno/Payment/beneficiary/dashboard.bru @@ -11,7 +11,7 @@ get { } params:path { - id: 2f6dfb20-7834-484c-8472-096f72fc5f08 + id: 16f41847-4bc4-4898-92d1-75fd314d15a8 } settings { diff --git a/app/components.json b/app/components.json index 2b0833f..6a73fe0 100644 --- a/app/components.json +++ b/app/components.json @@ -18,5 +18,7 @@ "lib": "@/lib", "hooks": "@/hooks" }, - "registries": {} + "registries": { + "@wds": "https://wds-shadcn-registry.netlify.app/r/{name}.json" + } } diff --git a/app/package.json b/app/package.json index ed77ae6..0d36bf0 100644 --- a/app/package.json +++ b/app/package.json @@ -18,12 +18,14 @@ "@module/payment": "workspace:*", "@radix-ui/react-avatar": "1.1.11", "@radix-ui/react-checkbox": "1.3.3", - "@radix-ui/react-dialog": "1.1.15", + "@radix-ui/react-collapsible": "^1.1.12", + "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "2.1.16", "@radix-ui/react-label": "2.1.8", + "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-select": "2.2.6", "@radix-ui/react-separator": "1.1.8", - "@radix-ui/react-slot": "1.2.4", + "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tabs": "1.1.13", "@radix-ui/react-toggle": "1.1.10", "@radix-ui/react-toggle-group": "1.1.11", @@ -32,9 +34,11 @@ "@tailwindcss/vite": "4.1.17", "@tanstack/react-router": "1.139.9", "@tanstack/react-table": "8.21.3", + "@valkyr/db": "npm:@jsr/valkyr__db@2.0.0", "@zitadel/react": "1.1.0", "class-variance-authority": "0.7.1", "clsx": "2.1.1", + "cmdk": "^1.1.1", "lucide-react": "0.555.0", "next-themes": "0.4.6", "react": "19.2.0", diff --git a/app/src/app/dashboard/dashboard.controller.ts b/app/src/app/dashboard/dashboard.controller.ts index 5b4de00..9175f8e 100644 --- a/app/src/app/dashboard/dashboard.controller.ts +++ b/app/src/app/dashboard/dashboard.controller.ts @@ -1,20 +1,3 @@ import { Controller } from "@/lib/controller.tsx"; -import { api } from "@/services/api.ts"; -export class DashboardController extends Controller<{ - beneficiaries: any[]; -}> { - async onInit() { - return { - beneficiaries: await this.#getBenficiaries(), - }; - } - - async #getBenficiaries() { - const response = await api.ledger.benficiaries.list(); - if ("error" in response) { - return []; - } - return response.data; - } -} +export class DashboardController extends Controller {} diff --git a/app/src/app/dashboard/dashboard.view.tsx b/app/src/app/dashboard/dashboard.view.tsx index b1415c4..f5507a9 100644 --- a/app/src/app/dashboard/dashboard.view.tsx +++ b/app/src/app/dashboard/dashboard.view.tsx @@ -2,11 +2,6 @@ import { makeControllerComponent } from "@/lib/controller.tsx"; import { DashboardController } from "./dashboard.controller.ts"; -export const DashboardView = makeControllerComponent(DashboardController, ({ beneficiaries }) => { - return ( -
- Dashboard -
{JSON.stringify(beneficiaries, null, 2)}
-
- ); +export const DashboardView = makeControllerComponent(DashboardController, () => { + return
Dashboard
; }); diff --git a/app/src/app/payment/dashboard/dashboard.controller.ts b/app/src/app/payment/dashboard/dashboard.controller.ts new file mode 100644 index 0000000..2aece52 --- /dev/null +++ b/app/src/app/payment/dashboard/dashboard.controller.ts @@ -0,0 +1,36 @@ +import type { Beneficiary } from "@module/payment/client"; + +import { loadBeneficiaries, payment } from "@/database/payment.ts"; +import { Controller } from "@/lib/controller.tsx"; +import { User } from "@/services/user.ts"; + +export class PaymentDashboardController extends Controller<{ + beneficiaries: Beneficiary[]; +}> { + #subscriptions: any[] = []; + + async onInit() { + await this.#subscribe(); + return { + isCreating: false, + beneficiaries: [], + }; + } + + async onDestroy(): Promise { + for (const subscription of this.#subscriptions) { + subscription.unsubscribe(); + } + } + + async #subscribe() { + await loadBeneficiaries(); + this.#subscriptions.push( + await payment + .collection("beneficiary") + .subscribe({ tenantId: await User.getTenantId() }, { sort: { label: 1 } }, (documents) => { + this.setState("beneficiaries", documents); + }), + ); + } +} diff --git a/app/src/app/payment/dashboard/dashboard.view.tsx b/app/src/app/payment/dashboard/dashboard.view.tsx new file mode 100644 index 0000000..2ad3c7f --- /dev/null +++ b/app/src/app/payment/dashboard/dashboard.view.tsx @@ -0,0 +1,22 @@ +import { makeControllerComponent } from "@/lib/controller.tsx"; + +import { PaymentDashboardController } from "./dashboard.controller.ts"; + +export const PaymentDashboardView = makeControllerComponent(PaymentDashboardController, ({ beneficiaries }) => { + if (beneficiaries.length === 0) { + return ( +
+

No Beneficiaries Found

+

+ This tenant does not have a beneficiaries assigned. A beneficiary entity is required to continue. +

+
+ ); + } + return ( +
+ Payments +
{JSON.stringify(beneficiaries, null, 2)}
+
+ ); +}); diff --git a/app/src/components/app-sidebar.controller.ts b/app/src/components/app-sidebar.controller.ts new file mode 100644 index 0000000..ba82086 --- /dev/null +++ b/app/src/components/app-sidebar.controller.ts @@ -0,0 +1,34 @@ +import { GalleryVerticalEnd } from "lucide-react"; + +import { Controller } from "@/lib/controller.tsx"; +import { User } from "@/services/user.ts"; + +type Tenant = { + name: string; + logo: React.ElementType; + domain: string; +}; + +export class AppSiderbarController extends Controller<{ + tenant: Tenant; +}> { + async onInit() { + const user = await User.resolve(); + if (user === undefined) { + return { + tenant: { + name: "Zitadel", + logo: GalleryVerticalEnd, + domain: "iam.valkyrjs.com", + }, + }; + } + return { + tenant: { + name: user.tenant.name, + logo: GalleryVerticalEnd, + domain: user.tenant.domain, + }, + }; + } +} diff --git a/app/src/components/app-sidebar.tsx b/app/src/components/app-sidebar.tsx index 3a437dd..a0bf091 100644 --- a/app/src/components/app-sidebar.tsx +++ b/app/src/components/app-sidebar.tsx @@ -1,26 +1,7 @@ -import * as React from "react" -import { - IconCamera, - IconChartBar, - IconDashboard, - IconDatabase, - IconFileAi, - IconFileDescription, - IconFileWord, - IconFolder, - IconHelp, - IconInnerShadowTop, - IconListDetails, - IconReport, - IconSearch, - IconSettings, - IconUsers, -} from "@tabler/icons-react" +import { Link } from "@tanstack/react-router"; +import { GalleryVerticalEnd } from "lucide-react"; -import { NavDocuments } from "@/components/nav-documents" -import { NavMain } from "@/components/nav-main" -import { NavSecondary } from "@/components/nav-secondary" -import { NavUser } from "@/components/nav-user" +import { NavUser } from "@/components/nav-user"; import { Sidebar, SidebarContent, @@ -29,151 +10,38 @@ import { SidebarMenu, SidebarMenuButton, SidebarMenuItem, -} from "@/components/ui/sidebar" +} from "@/components/ui/sidebar"; -const data = { - user: { - name: "shadcn", - email: "m@example.com", - avatar: "/avatars/shadcn.jpg", - }, - navMain: [ - { - title: "Dashboard", - url: "#", - icon: IconDashboard, - }, - { - title: "Lifecycle", - url: "#", - icon: IconListDetails, - }, - { - title: "Analytics", - url: "#", - icon: IconChartBar, - }, - { - title: "Projects", - url: "#", - icon: IconFolder, - }, - { - title: "Team", - url: "#", - icon: IconUsers, - }, - ], - navClouds: [ - { - title: "Capture", - icon: IconCamera, - isActive: true, - url: "#", - items: [ - { - title: "Active Proposals", - url: "#", - }, - { - title: "Archived", - url: "#", - }, - ], - }, - { - title: "Proposal", - icon: IconFileDescription, - url: "#", - items: [ - { - title: "Active Proposals", - url: "#", - }, - { - title: "Archived", - url: "#", - }, - ], - }, - { - title: "Prompts", - icon: IconFileAi, - url: "#", - items: [ - { - title: "Active Proposals", - url: "#", - }, - { - title: "Archived", - url: "#", - }, - ], - }, - ], - navSecondary: [ - { - title: "Settings", - url: "#", - icon: IconSettings, - }, - { - title: "Get Help", - url: "#", - icon: IconHelp, - }, - { - title: "Search", - url: "#", - icon: IconSearch, - }, - ], - documents: [ - { - name: "Data Library", - url: "#", - icon: IconDatabase, - }, - { - name: "Reports", - url: "#", - icon: IconReport, - }, - { - name: "Word Assistant", - url: "#", - icon: IconFileWord, - }, - ], -} +import { makeControllerComponent } from "../lib/controller.tsx"; +import { AppSiderbarController } from "./app-sidebar.controller.ts"; +import { NavPayment } from "./nav-payment.tsx"; -export function AppSidebar({ ...props }: React.ComponentProps) { +export const AppSidebar = makeControllerComponent(AppSiderbarController, ({ tenant }) => { return ( - + - - - - Acme Inc. - + + +
+ +
+
+ {tenant.name} + {tenant.domain} +
+
- - - + - +
- ) -} + ); +}); diff --git a/app/src/components/nav-main.tsx b/app/src/components/nav-main.tsx deleted file mode 100644 index 82afe7f..0000000 --- a/app/src/components/nav-main.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { IconCirclePlusFilled, IconMail, type Icon } from "@tabler/icons-react" - -import { Button } from "@/components/ui/button" -import { - SidebarGroup, - SidebarGroupContent, - SidebarMenu, - SidebarMenuButton, - SidebarMenuItem, -} from "@/components/ui/sidebar" - -export function NavMain({ - items, -}: { - items: { - title: string - url: string - icon?: Icon - }[] -}) { - return ( - - - - - - - Quick Create - - - - - - {items.map((item) => ( - - - {item.icon && } - {item.title} - - - ))} - - - - ) -} diff --git a/app/src/components/nav-payment.controller.ts b/app/src/components/nav-payment.controller.ts new file mode 100644 index 0000000..a8cb04e --- /dev/null +++ b/app/src/components/nav-payment.controller.ts @@ -0,0 +1,85 @@ +import type { Beneficiary } from "@module/payment/client"; +import { Book, CirclePlusIcon, type LucideIcon, SquareTerminal } from "lucide-react"; +import type { FunctionComponent } from "react"; + +import { loadBeneficiaries, payment } from "@/database/payment.ts"; +import { Controller } from "@/lib/controller.tsx"; +import { User } from "@/services/user.ts"; + +import { DialogCreateBeneficiary } from "./payment/create-beneficiary.tsx"; + +export class NavPaymentController extends Controller<{ + items: MenuItem[]; +}> { + #subscriptions: any[] = []; + + async onInit() { + await this.#subscribe(); + return { + items: [ + { + title: "Dashboard", + url: "/payment", + icon: SquareTerminal, + }, + ], + }; + } + + async #subscribe() { + await loadBeneficiaries(); + this.#subscriptions.push( + await payment + .collection("beneficiary") + .subscribe({ tenantId: await User.getTenantId() }, { sort: { label: 1 } }, (beneficiaries) => { + this.setState("items", this.#getMenuItems(beneficiaries)); + }), + ); + } + + #getMenuItems(beneficiaries: Beneficiary[]): MenuItem[] { + return [ + { + title: "Dashboard", + url: "/payment", + icon: SquareTerminal, + }, + { + title: "Beneficiaries", + url: "#", + icon: Book, + isActive: true, + items: [ + { + title: "Create Beneficiary", + icon: CirclePlusIcon, + component: DialogCreateBeneficiary, + }, + ...beneficiaries.map((beneficiary) => ({ + title: beneficiary.label ?? "Unlabeled", + url: `/payment/${beneficiary._id}`, + })), + ], + }, + ]; + } +} + +type MenuItem = { + title: string; + url: string; + icon?: LucideIcon; + isActive?: boolean; + items?: SubMenuItem[]; +}; + +type SubMenuItem = + | { + title: string; + url: string; + } + | { + title: string; + icon: LucideIcon; + component: FunctionComponent<{ title: string; icon: LucideIcon }>; + }; diff --git a/app/src/components/nav-payment.tsx b/app/src/components/nav-payment.tsx new file mode 100644 index 0000000..299f989 --- /dev/null +++ b/app/src/components/nav-payment.tsx @@ -0,0 +1,78 @@ +"use client"; + +import { Link } from "@tanstack/react-router"; +import { ChevronRight } from "lucide-react"; + +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; +import { + SidebarGroup, + SidebarGroupLabel, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarMenuSub, + SidebarMenuSubButton, + SidebarMenuSubItem, +} from "@/components/ui/sidebar"; + +import { makeControllerComponent } from "../lib/controller.tsx"; +import { NavPaymentController } from "./nav-payment.controller.ts"; + +export const NavPayment = makeControllerComponent(NavPaymentController, ({ items }) => { + return ( + + Payment + + {items.map((item) => { + if (item.items !== undefined) { + return ( + + + + + {item.icon && } + {item.title} + + + + + + {item.items?.map((subItem) => { + if ("component" in subItem) { + return ( + + + + ); + } + return ( + + + + {subItem.title} + + + + ); + })} + + + + + ); + } + return ( + + + + {item.icon && } + {item.title} + + + + ); + })} + + + ); +}); diff --git a/app/src/components/nav-user.controller.ts b/app/src/components/nav-user.controller.ts index 6f8def2..76397ca 100644 --- a/app/src/components/nav-user.controller.ts +++ b/app/src/components/nav-user.controller.ts @@ -1,5 +1,6 @@ import { Controller } from "@/lib/controller.tsx"; -import { type User as ZitadelUser, zitadel } from "@/services/zitadel.ts"; +import { User } from "@/services/user.ts"; +import { zitadel } from "@/services/zitadel.ts"; export class NavUserController extends Controller<{ user: User; @@ -11,38 +12,14 @@ export class NavUserController extends Controller<{ } async #getAuthenticatedUser(): Promise { - const user = await zitadel.userManager.getUser(); - if (user === null) { + const user = await User.resolve(); + if (user === undefined) { throw new Error("Failed to resolve user session"); } - return getUserProfile(user); + return user; } signout() { zitadel.signout(); } } - -function getUserProfile({ profile }: ZitadelUser): User { - const user: User = { name: "Unknown", email: "unknown@acme.none", avatar: "" }; - if (profile.name) { - user.name = profile.name; - } else if (profile.given_name && profile.family_name) { - user.name = `${profile.given_name} ${profile.family_name}`; - } else if (profile.given_name) { - user.name = profile.given_name; - } - if (profile.email) { - user.email = profile.email; - } - if (profile.picture !== undefined) { - user.avatar = profile.picture; - } - return user; -} - -type User = { - name: string; - email: string; - avatar: string; -}; diff --git a/app/src/components/payment/create-beneficiary.controller.ts b/app/src/components/payment/create-beneficiary.controller.ts new file mode 100644 index 0000000..20486bb --- /dev/null +++ b/app/src/components/payment/create-beneficiary.controller.ts @@ -0,0 +1,41 @@ +import type { LucideIcon } from "lucide-react"; + +import { payment } from "@/database/payment.ts"; +import { Controller } from "@/lib/controller.tsx"; +import { api, getSuccessResponse } from "@/services/api.ts"; +import { User } from "@/services/user.ts"; + +export class CreateBeneficiaryController extends Controller< + { + isSubmitting: boolean; + }, + { title: string; icon: LucideIcon } +> { + #label?: string; + + async onInit() { + return { + isSubmitting: false, + }; + } + + setLabel(label: string) { + this.#label = label; + } + + async submit() { + this.setState("isSubmitting", true); + const res = await api.payment.benficiaries.create({ + body: { + tenantId: await User.getTenantId(), + label: this.#label, + }, + }); + if ("data" in res) { + await payment + .collection("beneficiary") + .insertOne(await getSuccessResponse(api.payment.benficiaries.id({ params: { id: res.data } }))); + } + this.setState("isSubmitting", false); + } +} diff --git a/app/src/components/payment/create-beneficiary.tsx b/app/src/components/payment/create-beneficiary.tsx new file mode 100644 index 0000000..3015786 --- /dev/null +++ b/app/src/components/payment/create-beneficiary.tsx @@ -0,0 +1,58 @@ +import { useId } from "react"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { LoadingSwap } from "@/components/ui/loading-swap"; +import { SidebarMenuSubButton } from "@/components/ui/sidebar.tsx"; + +import { makeControllerComponent } from "../../lib/controller.tsx"; +import { CreateBeneficiaryController } from "./create-beneficiary.controller.ts"; + +export const DialogCreateBeneficiary = makeControllerComponent( + CreateBeneficiaryController, + ({ title, setLabel, submit, isSubmitting, ...props }) => { + const labelId = useId(); + return ( + +
+ + + {title} + + + + + Create beneficiary + Create a payment ledger and its supported currencies + +
+
+ + setLabel(value)} /> +
+
+ + + + + + +
+
+
+ ); + }, +); diff --git a/app/src/components/payment/create-ledger.controller.ts b/app/src/components/payment/create-ledger.controller.ts new file mode 100644 index 0000000..d75989e --- /dev/null +++ b/app/src/components/payment/create-ledger.controller.ts @@ -0,0 +1,43 @@ +import { CurrencySchema } from "@module/payment/client"; +import type { LucideIcon } from "lucide-react"; + +import { api } from "@/services/api.ts"; + +import { Controller } from "../../lib/controller.tsx"; + +export class CreateLedgerController extends Controller< + { + isSubmitting: boolean; + }, + { title: string; icon: LucideIcon } +> { + #label?: string; + #currencies: string[] = []; + + async onInit() { + return { + isSubmitting: false, + }; + } + + setLabel(label: string) { + this.#label = label; + } + + setCurrencies(currencies: string[]) { + this.#currencies = currencies; + } + + async submit() { + this.setState("isSubmitting", true); + const result = await api.payment.ledger.create({ + body: { + beneficiaryId: crypto.randomUUID(), + label: this.#label, + currencies: this.#currencies.map((currency) => CurrencySchema.parse(currency)), + }, + }); + console.log(result); + this.setState("isSubmitting", false); + } +} diff --git a/app/src/components/payment/create-ledger.tsx b/app/src/components/payment/create-ledger.tsx new file mode 100644 index 0000000..e7e5ad0 --- /dev/null +++ b/app/src/components/payment/create-ledger.tsx @@ -0,0 +1,84 @@ +import { currencies } from "@module/payment/client"; +import { useId } from "react"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { LoadingSwap } from "@/components/ui/loading-swap"; +import { + MultiSelect, + MultiSelectContent, + MultiSelectGroup, + MultiSelectItem, + MultiSelectTrigger, + MultiSelectValue, +} from "@/components/ui/multi-select"; +import { SidebarMenuSubButton } from "@/components/ui/sidebar.tsx"; + +import { makeControllerComponent } from "../../lib/controller.tsx"; +import { CreateLedgerController } from "./create-ledger.controller.ts"; + +export const DialogCreateLedger = makeControllerComponent( + CreateLedgerController, + ({ title, setLabel, setCurrencies, submit, isSubmitting, ...props }) => { + const labelId = useId(); + return ( + +
+ + + {title} + + + + + Create ledger + Create a payment ledger and its supported currencies + +
+
+ + setLabel(value)} /> +
+
+ + + + + + + + {currencies.map((currency) => ( + + {currency} + + ))} + + + +
+
+ + + + + + +
+
+
+ ); + }, +); diff --git a/app/src/components/tenant-switcher.controller.ts b/app/src/components/tenant-switcher.controller.ts new file mode 100644 index 0000000..e70bd96 --- /dev/null +++ b/app/src/components/tenant-switcher.controller.ts @@ -0,0 +1,45 @@ +import { AudioWaveform, Command, GalleryVerticalEnd } from "lucide-react"; + +import { Controller } from "@/lib/controller.tsx"; + +type Tenant = { + name: string; + logo: React.ElementType; + plan: string; +}; + +export class TenantSwitcherController extends Controller<{ + activeTenant?: Tenant; + tenants: Tenant[]; +}> { + async onInit() { + return { + activeTenant: { + name: "Acme Inc", + logo: GalleryVerticalEnd, + plan: "Enterprise", + }, + tenants: [ + { + name: "Acme Inc", + logo: GalleryVerticalEnd, + plan: "Enterprise", + }, + { + name: "Acme Corp.", + logo: AudioWaveform, + plan: "Startup", + }, + { + name: "Evil Corp.", + logo: Command, + plan: "Free", + }, + ], + }; + } + + setActiveTenant(tenant: Tenant) { + this.setState("activeTenant", tenant); + } +} diff --git a/app/src/components/tenant-switcher.tsx b/app/src/components/tenant-switcher.tsx new file mode 100644 index 0000000..1659ef6 --- /dev/null +++ b/app/src/components/tenant-switcher.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { ChevronsUpDown, Plus } from "lucide-react"; + +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { SidebarMenu, SidebarMenuButton, SidebarMenuItem, useSidebar } from "@/components/ui/sidebar"; + +import { makeControllerComponent } from "../lib/controller.tsx"; +import { TenantSwitcherController } from "./tenant-switcher.controller.ts"; + +export const TenantSwitcher = makeControllerComponent( + TenantSwitcherController, + ({ tenants, activeTenant, setActiveTenant }) => { + const { isMobile } = useSidebar(); + if (activeTenant === undefined) { + return null; + } + return ( + + + + + +
+ +
+
+ {activeTenant.name} + {activeTenant.plan} +
+ +
+
+ + Teams + {tenants.map((tenant, index) => ( + setActiveTenant(tenant)} className="gap-2 p-2"> +
+ +
+ {tenant.name} + ⌘{index + 1} +
+ ))} + + +
+ +
+
Add team
+
+
+
+
+
+ ); + }, +); diff --git a/app/src/components/ui/button.tsx b/app/src/components/ui/button.tsx index 21409a0..1eede63 100644 --- a/app/src/components/ui/button.tsx +++ b/app/src/components/ui/button.tsx @@ -1,8 +1,8 @@ -import * as React from "react" -import { Slot } from "@radix-ui/react-slot" -import { cva, type VariantProps } from "class-variance-authority" +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; +import type * as React from "react"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; const buttonVariants = cva( "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", @@ -14,10 +14,8 @@ const buttonVariants = cva( "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", outline: "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", - secondary: - "bg-secondary text-secondary-foreground hover:bg-secondary/80", - ghost: - "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", + secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", link: "text-primary underline-offset-4 hover:underline", }, size: { @@ -33,8 +31,8 @@ const buttonVariants = cva( variant: "default", size: "default", }, - } -) + }, +); function Button({ className, @@ -44,17 +42,11 @@ function Button({ ...props }: React.ComponentProps<"button"> & VariantProps & { - asChild?: boolean + asChild?: boolean; }) { - const Comp = asChild ? Slot : "button" + const Comp = asChild ? Slot : "button"; - return ( - - ) + return ; } -export { Button, buttonVariants } +export { Button, buttonVariants }; diff --git a/app/src/components/ui/collapsible.tsx b/app/src/components/ui/collapsible.tsx new file mode 100644 index 0000000..77f86be --- /dev/null +++ b/app/src/components/ui/collapsible.tsx @@ -0,0 +1,31 @@ +import * as CollapsiblePrimitive from "@radix-ui/react-collapsible" + +function Collapsible({ + ...props +}: React.ComponentProps) { + return +} + +function CollapsibleTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CollapsibleContent({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Collapsible, CollapsibleTrigger, CollapsibleContent } diff --git a/app/src/components/ui/command.tsx b/app/src/components/ui/command.tsx new file mode 100644 index 0000000..e5bd56b --- /dev/null +++ b/app/src/components/ui/command.tsx @@ -0,0 +1,182 @@ +import * as React from "react" +import { Command as CommandPrimitive } from "cmdk" +import { SearchIcon } from "lucide-react" + +import { cn } from "@/lib/utils" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" + +function Command({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandDialog({ + title = "Command Palette", + description = "Search for a command to run...", + children, + className, + showCloseButton = true, + ...props +}: React.ComponentProps & { + title?: string + description?: string + className?: string + showCloseButton?: boolean +}) { + return ( + + + {title} + {description} + + + + {children} + + + + ) +} + +function CommandInput({ + className, + ...props +}: React.ComponentProps) { + return ( +
+ + +
+ ) +} + +function CommandList({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandEmpty({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandGroup({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ) +} + +export { + Command, + CommandDialog, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, + CommandShortcut, + CommandSeparator, +} diff --git a/app/src/components/ui/dialog.tsx b/app/src/components/ui/dialog.tsx new file mode 100644 index 0000000..6cb123b --- /dev/null +++ b/app/src/components/ui/dialog.tsx @@ -0,0 +1,141 @@ +import * as React from "react" +import * as DialogPrimitive from "@radix-ui/react-dialog" +import { XIcon } from "lucide-react" + +import { cn } from "@/lib/utils" + +function Dialog({ + ...props +}: React.ComponentProps) { + return +} + +function DialogTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function DialogPortal({ + ...props +}: React.ComponentProps) { + return +} + +function DialogClose({ + ...props +}: React.ComponentProps) { + return +} + +function DialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogContent({ + className, + children, + showCloseButton = true, + ...props +}: React.ComponentProps & { + showCloseButton?: boolean +}) { + return ( + + + + {children} + {showCloseButton && ( + + + Close + + )} + + + ) +} + +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +} diff --git a/app/src/components/ui/loading-swap.tsx b/app/src/components/ui/loading-swap.tsx new file mode 100644 index 0000000..cdcd76e --- /dev/null +++ b/app/src/components/ui/loading-swap.tsx @@ -0,0 +1,33 @@ +import { Loader2Icon } from "lucide-react"; +import type { ReactNode } from "react"; + +import { cn } from "@/lib/utils"; + +export function LoadingSwap({ + isLoading, + children, + className, +}: { + isLoading: boolean; + children: ReactNode; + className?: string; +}) { + return ( +
+
+ {children} +
+
+ +
+
+ ); +} diff --git a/app/src/components/ui/multi-select.tsx b/app/src/components/ui/multi-select.tsx new file mode 100644 index 0000000..2d59a07 --- /dev/null +++ b/app/src/components/ui/multi-select.tsx @@ -0,0 +1,325 @@ +"use client"; + +import { CheckIcon, ChevronsUpDownIcon, XIcon } from "lucide-react"; +import { + type ComponentPropsWithoutRef, + createContext, + type ReactNode, + useCallback, + useContext, + useEffect, + useRef, + useState, +} from "react"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, +} from "@/components/ui/command"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { cn } from "@/lib/utils"; + +type MultiSelectContextType = { + open: boolean; + setOpen: (open: boolean) => void; + selectedValues: Set; + toggleValue: (value: string) => void; + items: Map; + onItemAdded: (value: string, label: ReactNode) => void; +}; +const MultiSelectContext = createContext(null); + +export function MultiSelect({ + children, + values, + defaultValues, + onValuesChange, +}: { + children: ReactNode; + values?: string[]; + defaultValues?: string[]; + onValuesChange?: (values: string[]) => void; +}) { + const [open, setOpen] = useState(false); + const [internalValues, setInternalValues] = useState(new Set(values ?? defaultValues)); + const selectedValues = values ? new Set(values) : internalValues; + const [items, setItems] = useState>(new Map()); + + function toggleValue(value: string) { + const getNewSet = (prev: Set) => { + const newSet = new Set(prev); + if (newSet.has(value)) { + newSet.delete(value); + } else { + newSet.add(value); + } + return newSet; + }; + setInternalValues(getNewSet); + onValuesChange?.([...getNewSet(selectedValues)]); + } + + const onItemAdded = useCallback((value: string, label: ReactNode) => { + setItems((prev) => { + if (prev.get(value) === label) return prev; + return new Map(prev).set(value, label); + }); + }, []); + + return ( + + + {children} + + + ); +} + +export function MultiSelectTrigger({ + className, + children, + ...props +}: { + className?: string; + children?: ReactNode; +} & ComponentPropsWithoutRef) { + const { open } = useMultiSelectContext(); + + return ( + + + + ); +} + +export function MultiSelectValue({ + placeholder, + clickToRemove = true, + className, + overflowBehavior = "wrap-when-open", + ...props +}: { + placeholder?: string; + clickToRemove?: boolean; + overflowBehavior?: "wrap" | "wrap-when-open" | "cutoff"; +} & Omit, "children">) { + const { selectedValues, toggleValue, items, open } = useMultiSelectContext(); + const [overflowAmount, setOverflowAmount] = useState(0); + const valueRef = useRef(null); + const overflowRef = useRef(null); + + const shouldWrap = overflowBehavior === "wrap" || (overflowBehavior === "wrap-when-open" && open); + + const checkOverflow = useCallback(() => { + if (valueRef.current == null) { + return; + } + + const containerElement = valueRef.current; + const overflowElement = overflowRef.current; + const items = containerElement.querySelectorAll("[data-selected-item]"); + + if (overflowElement != null) { + overflowElement.style.display = "none"; + } + + for (const child of items) { + child.style.removeProperty("display"); + } + + let amount = 0; + for (let i = items.length - 1; i >= 0; i--) { + const child = items[i]; + if (containerElement.scrollWidth <= containerElement.clientWidth) { + break; + } + amount = items.length - i; + child.style.display = "none"; + overflowElement?.style.removeProperty("display"); + } + setOverflowAmount(amount); + }, []); + + const handleResize = useCallback( + (node: HTMLDivElement) => { + valueRef.current = node; + + const mutationObserver = new MutationObserver(checkOverflow); + const observer = new ResizeObserver(debounce(checkOverflow, 100)); + + mutationObserver.observe(node, { + childList: true, + attributes: true, + attributeFilter: ["class", "style"], + }); + observer.observe(node); + + return () => { + observer.disconnect(); + mutationObserver.disconnect(); + valueRef.current = null; + }; + }, + [checkOverflow], + ); + + if (selectedValues.size === 0 && placeholder) { + return {placeholder}; + } + + return ( +
+ {[...selectedValues] + .filter((value) => items.has(value)) + .map((value) => ( + { + e.stopPropagation(); + toggleValue(value); + } + : undefined + } + > + {items.get(value)} + {clickToRemove && } + + ))} + 0 && !shouldWrap ? "block" : "none", + }} + variant="outline" + ref={overflowRef} + > + +{overflowAmount} + +
+ ); +} + +export function MultiSelectContent({ + search = true, + children, + ...props +}: { + search?: boolean | { placeholder?: string; emptyMessage?: string }; + children: ReactNode; +} & Omit, "children">) { + const canSearch = typeof search === "object" ? true : search; + + return ( + <> +
+ + {children} + +
+ + + {canSearch ? ( + + ) : ( +