diff --git a/apps/api/src/locales/@vitnode/core/en.json b/apps/api/src/locales/@vitnode/core/en.json index ba4d07844..f84da55e7 100644 --- a/apps/api/src/locales/@vitnode/core/en.json +++ b/apps/api/src/locales/@vitnode/core/en.json @@ -99,8 +99,11 @@ } }, "user_bar": { + "my_profile": "My Profile", "log_out": "Log out", - "admin_cp": "Admin CP" + "mod_cp": "Moderator CP", + "admin_cp": "Admin CP", + "settings": "Settings" } }, "auth": { @@ -204,6 +207,14 @@ "title": "Password changed successfully", "desc": "You can now log in with your new password." } + }, + "settings": { + "title": "Settings", + "desc": "Manage your profile, security, and account preferences.", + "nav": { + "overview": "Overview", + "security": "Security" + } } } }, @@ -264,8 +275,9 @@ "user": "User", "createdAt": "Created At", "emailNotVerified": "Email Not Verified", - "view": "View profile", + "edit": "Edit profile", "roles": "Roles", + "email": "Email", "searchPlaceholder": "Search users by email or username...", "noResults": { "title": "No users found", @@ -276,7 +288,26 @@ "title": "User Profile", "joined": "Joined", "emailNotVerified": "Email Not Verified", - "coverPlaceholder": "No cover image" + "coverPlaceholder": "No cover image", + "editCover": "Edit cover image", + "editAvatar": "Edit avatar", + "editName": "Edit username", + "editEmail": "Edit email", + "uploadImage": "Upload image", + "removeImage": "Remove image", + "goToProfile": "Go to Public Profile", + "updateSuccess": "Profile updated.", + "editNameCode": "Edit name code", + "editNameCodeDesc": "The name code is the unique handle used in this user's profile URL and @mentions.", + "editNameCodeWarningTitle": "This change breaks existing links", + "editNameCodeWarning": "Changing the name code will break existing @mention links and change the public profile URL. Anyone visiting the old link will no longer reach this profile.", + "newNameCode": "New name code", + "confirmNameCode": "Type the current name code () to confirm", + "nameCodeInvalid": "Only letters, numbers, and hyphens are allowed.", + "nameCodeSame": "The new name code must be different from the current one.", + "nameCodeConfirmMismatch": "This does not match the current name code.", + "nameCodeExists": "This name code is already taken.", + "saveNameCode": "Change name code" }, "verify_email": { "label": "Verify Email", diff --git a/apps/docs/content/docs/ui/accordion.mdx b/apps/docs/content/docs/ui/accordion.mdx index 75e0f895e..75d60cf12 100644 --- a/apps/docs/content/docs/ui/accordion.mdx +++ b/apps/docs/content/docs/ui/accordion.mdx @@ -19,7 +19,7 @@ import { ``` ```tsx - + Product Information @@ -67,4 +67,4 @@ import { ## API Reference -[Radix UI - Accordion](https://www.radix-ui.com/primitives/docs/components/accordion#api-reference) +[Base UI - Accordion](https://base-ui.com/react/components/accordion) diff --git a/apps/docs/content/docs/ui/alert-dialog.mdx b/apps/docs/content/docs/ui/alert-dialog.mdx index 0ad799fa1..0b70f474d 100644 --- a/apps/docs/content/docs/ui/alert-dialog.mdx +++ b/apps/docs/content/docs/ui/alert-dialog.mdx @@ -3,6 +3,50 @@ title: Alert Dialog description: Display important messages to users in a modal dialog. --- - - We're working hard to bring you the best documentation experience. - +A modal dialog that interrupts the user with important content and expects a +response. Unlike a regular [Dialog](/docs/ui/dialog), it can only be dismissed +through one of its actions. + +## Preview + + + +## Usage + +```ts +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@vitnode/core/components/ui/alert-dialog'; +import { Button } from '@vitnode/core/components/ui/button'; +``` + +```tsx + + Show Dialog} /> + + + Are you absolutely sure? + + This action cannot be undone. This will permanently delete your account + and remove your data from our servers. + + + + Cancel + Continue + + + +``` + +## API Reference + +[Base UI - Alert Dialog](https://base-ui.com/react/components/alert-dialog#api-reference) diff --git a/apps/docs/content/docs/ui/button.mdx b/apps/docs/content/docs/ui/button.mdx index 996682188..d82c366eb 100644 --- a/apps/docs/content/docs/ui/button.mdx +++ b/apps/docs/content/docs/ui/button.mdx @@ -21,6 +21,29 @@ import { Button } from "@vitnode/core/components/ui/button"; ``` +## Link + +Use the `render` prop to swap the underlying element or compose the button with +another component, such as a link. Set `nativeButton={false}` when the rendered +element is not a native ` +``` + +## Loading + +Pass `isLoading` to render a spinner and disable the button while an action is +in progress. + +```tsx + +``` + ## Props import { TypeTable } from "fumadocs-ui/components/type-table"; @@ -34,8 +57,27 @@ import { TypeTable } from "fumadocs-ui/components/type-table"; }, size: { description: "The size of the button.", - type: "default | sm | lg | icon", + type: "default | xs | sm | lg | icon | icon-xs | icon-sm | icon-lg", default: "default", }, + isLoading: { + description: "Shows a loading spinner and disables the button.", + type: "boolean", + }, + render: { + description: + "A React element to render in place of the default button element.", + type: "ReactElement", + }, + nativeButton: { + description: + "Whether the rendered element is a native button. Set to false when using `render` with a non-button element.", + type: "boolean", + default: "true", + }, }} /> + +## API Reference + +[Base UI - Button](https://base-ui.com/react/components/button#api-reference) diff --git a/apps/docs/content/docs/ui/checkbox.mdx b/apps/docs/content/docs/ui/checkbox.mdx index 0cc420a80..8f00b7bb2 100644 --- a/apps/docs/content/docs/ui/checkbox.mdx +++ b/apps/docs/content/docs/ui/checkbox.mdx @@ -53,7 +53,7 @@ import { Checkbox } from '@vitnode/core/components/ui/checkbox'; ``` ```tsx - + ``` @@ -79,3 +79,7 @@ import { TypeTable } from "fumadocs-ui/components/type-table"; }, }} /> + +## API Reference + +[Base UI - Checkbox](https://base-ui.com/react/components/checkbox#api-reference) diff --git a/apps/docs/content/docs/ui/dialog.mdx b/apps/docs/content/docs/ui/dialog.mdx index d99918ec2..810d3cbd5 100644 --- a/apps/docs/content/docs/ui/dialog.mdx +++ b/apps/docs/content/docs/ui/dialog.mdx @@ -3,6 +3,45 @@ title: Dialog description: Display content in a modal dialog. --- - - We're working hard to bring you the best documentation experience. - +## Preview + + + +## Usage + +```ts +import { Button } from '@vitnode/core/components/ui/button'; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@vitnode/core/components/ui/dialog'; +``` + +```tsx + + Open} /> + + + Are you absolutely sure? + + This action cannot be undone. This will permanently delete your account + and remove your data from our servers. + + + + Cancel} /> + + + + +``` + +## API Reference + +[Base UI - Dialog](https://base-ui.com/react/components/dialog#api-reference) diff --git a/apps/docs/content/docs/ui/dropdown-menu.mdx b/apps/docs/content/docs/ui/dropdown-menu.mdx index f026ca9b0..1120a29ef 100644 --- a/apps/docs/content/docs/ui/dropdown-menu.mdx +++ b/apps/docs/content/docs/ui/dropdown-menu.mdx @@ -16,7 +16,6 @@ import { DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, - DropdownMenuPortal, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuSub, @@ -28,8 +27,8 @@ import { ```tsx - - + }> + Open My Account @@ -56,14 +55,12 @@ import { Team Invite users - - - Email - Message - - More... - - + + Email + Message + + More... + New Team @@ -85,4 +82,4 @@ import { ## API Reference -[Radix UI - Dropdown Menu](https://www.radix-ui.com/primitives/docs/components/dropdown-menu#api-reference) +[Base UI - Menu](https://base-ui.com/react/components/menu#api-reference) diff --git a/apps/docs/content/docs/ui/hover-card.mdx b/apps/docs/content/docs/ui/hover-card.mdx index a0b4a27d2..3ba044f21 100644 --- a/apps/docs/content/docs/ui/hover-card.mdx +++ b/apps/docs/content/docs/ui/hover-card.mdx @@ -28,4 +28,4 @@ import { ## API Reference -[Radix UI - Hover Card](https://www.radix-ui.com/primitives/docs/components/hover-card#api-reference) +[Base UI - Preview Card](https://base-ui.com/react/components/preview-card#api-reference) diff --git a/apps/docs/content/docs/ui/popover.mdx b/apps/docs/content/docs/ui/popover.mdx index d33922bee..ef4e8e5a2 100644 --- a/apps/docs/content/docs/ui/popover.mdx +++ b/apps/docs/content/docs/ui/popover.mdx @@ -20,13 +20,11 @@ import { ```tsx - - - + Open} /> Place content for the popover here. ``` ## API Reference -[Radix UI - Popover](https://www.radix-ui.com/primitives/docs/components/popover#api-reference) +[Base UI - Popover](https://base-ui.com/react/components/popover#api-reference) diff --git a/apps/docs/content/docs/ui/progress.mdx b/apps/docs/content/docs/ui/progress.mdx index d5aa8f419..45072044a 100644 --- a/apps/docs/content/docs/ui/progress.mdx +++ b/apps/docs/content/docs/ui/progress.mdx @@ -19,4 +19,4 @@ import { Progress } from '@vitnode/core/components/ui/progress'; ## API Reference -[Radix UI - Progress](https://www.radix-ui.com/primitives/docs/components/progress#api-reference) +[Base UI - Progress](https://base-ui.com/react/components/progress#api-reference) diff --git a/apps/docs/content/docs/ui/scroll-area.mdx b/apps/docs/content/docs/ui/scroll-area.mdx index f57794926..25923972d 100644 --- a/apps/docs/content/docs/ui/scroll-area.mdx +++ b/apps/docs/content/docs/ui/scroll-area.mdx @@ -37,4 +37,4 @@ export function ScrollAreaDemo() { ## API Reference -[Radix UI - Scroll area](https://www.radix-ui.com/primitives/docs/components/scroll-area#api-reference) +[Base UI - Scroll Area](https://base-ui.com/react/components/scroll-area#api-reference) diff --git a/apps/docs/content/docs/ui/select.mdx b/apps/docs/content/docs/ui/select.mdx index 4c24d5cf8..860b868df 100644 --- a/apps/docs/content/docs/ui/select.mdx +++ b/apps/docs/content/docs/ui/select.mdx @@ -72,17 +72,28 @@ import { ``` ```tsx - - Option One - Option Two + {items.map((item) => ( + + {item.label} + + ))} ``` +> Pass an `items` array to `Select` so `SelectValue` can render the selected +> option's label. Without it, the raw `value` is displayed instead. + @@ -121,4 +132,4 @@ import { TypeTable } from "fumadocs-ui/components/type-table"; ## API Reference -[Radix UI - Select](https://www.radix-ui.com/primitives/docs/components/select#api-reference) +[Base UI - Select](https://base-ui.com/react/components/select#api-reference) diff --git a/apps/docs/content/docs/ui/separator.mdx b/apps/docs/content/docs/ui/separator.mdx index c936954ea..1d0ed556d 100644 --- a/apps/docs/content/docs/ui/separator.mdx +++ b/apps/docs/content/docs/ui/separator.mdx @@ -16,7 +16,7 @@ import { Separator } from '@vitnode/core/components/ui/separator'; ```tsx
-

Radix Primitives

+

Base UI

An open-source UI component library.

@@ -34,4 +34,4 @@ import { Separator } from '@vitnode/core/components/ui/separator'; ## API Reference -[Radix UI - Separator](https://www.radix-ui.com/primitives/docs/components/separator#api-reference) +[Base UI - Separator](https://base-ui.com/react/components/separator#api-reference) diff --git a/apps/docs/content/docs/ui/sheet.mdx b/apps/docs/content/docs/ui/sheet.mdx index c6d4a6123..03103f2f6 100644 --- a/apps/docs/content/docs/ui/sheet.mdx +++ b/apps/docs/content/docs/ui/sheet.mdx @@ -25,9 +25,7 @@ import { ```tsx - - - + Open} /> Are you absolutely sure? @@ -38,9 +36,7 @@ import { - - - + Cancel} /> @@ -48,4 +44,4 @@ import { ## API Reference -[Radix UI - Dialog](https://www.radix-ui.com/primitives/docs/components/dialog#api-reference) +[Base UI - Dialog](https://base-ui.com/react/components/dialog#api-reference) diff --git a/apps/docs/content/docs/ui/switch.mdx b/apps/docs/content/docs/ui/switch.mdx index c9b6197a8..9ef16fc14 100644 --- a/apps/docs/content/docs/ui/switch.mdx +++ b/apps/docs/content/docs/ui/switch.mdx @@ -58,4 +58,4 @@ import { Switch } from '@vitnode/core/components/ui/switch'; ## API Reference -[Radix UI - Switch](https://www.radix-ui.com/primitives/docs/components/switch#api-reference) +[Base UI - Switch](https://base-ui.com/react/components/switch#api-reference) diff --git a/apps/docs/src/app/[locale]/(main)/(plugins)/(vitnode-core)/settings/layout.tsx b/apps/docs/src/app/[locale]/(main)/(plugins)/(vitnode-core)/settings/layout.tsx new file mode 100644 index 000000000..175467cc1 --- /dev/null +++ b/apps/docs/src/app/[locale]/(main)/(plugins)/(vitnode-core)/settings/layout.tsx @@ -0,0 +1,16 @@ +import type { Metadata } from "next/dist/types"; + +import { LayoutSettings } from "@vitnode/core/views/auth/settings/layout"; + +export const metadata: Metadata = { + robots: { + index: false, + follow: false, + }, +}; + +export default function Layout( + props: React.ComponentProps, +) { + return ; +} diff --git a/apps/docs/src/app/[locale]/(main)/(plugins)/(vitnode-core)/settings/overview/page.tsx b/apps/docs/src/app/[locale]/(main)/(plugins)/(vitnode-core)/settings/overview/page.tsx new file mode 100644 index 000000000..5ca3460f8 --- /dev/null +++ b/apps/docs/src/app/[locale]/(main)/(plugins)/(vitnode-core)/settings/overview/page.tsx @@ -0,0 +1,15 @@ +import { getTranslations } from "next-intl/server"; + +import { OverviewSettings } from "@vitnode/core/views/auth/settings/overview/overview"; + +export const generateMetadata = async () => { + const t = await getTranslations("core.auth.settings"); + + return { + title: `${t("nav.overview")} - ${t("title")}`, + }; +}; + +export default function Page() { + return ; +} diff --git a/apps/docs/src/app/[locale]/(main)/(plugins)/(vitnode-core)/settings/page.tsx b/apps/docs/src/app/[locale]/(main)/(plugins)/(vitnode-core)/settings/page.tsx new file mode 100644 index 000000000..5ca3460f8 --- /dev/null +++ b/apps/docs/src/app/[locale]/(main)/(plugins)/(vitnode-core)/settings/page.tsx @@ -0,0 +1,15 @@ +import { getTranslations } from "next-intl/server"; + +import { OverviewSettings } from "@vitnode/core/views/auth/settings/overview/overview"; + +export const generateMetadata = async () => { + const t = await getTranslations("core.auth.settings"); + + return { + title: `${t("nav.overview")} - ${t("title")}`, + }; +}; + +export default function Page() { + return ; +} diff --git a/apps/docs/src/app/[locale]/(main)/(plugins)/(vitnode-core)/settings/security/page.tsx b/apps/docs/src/app/[locale]/(main)/(plugins)/(vitnode-core)/settings/security/page.tsx new file mode 100644 index 000000000..26e4e3a29 --- /dev/null +++ b/apps/docs/src/app/[locale]/(main)/(plugins)/(vitnode-core)/settings/security/page.tsx @@ -0,0 +1,15 @@ +import { getTranslations } from "next-intl/server"; + +import { SecuritySettings } from "@vitnode/core/views/auth/settings/security/security"; + +export const generateMetadata = async () => { + const t = await getTranslations("core.auth.settings"); + + return { + title: `${t("nav.security")} - ${t("title")}`, + }; +}; + +export default function Page() { + return ; +} diff --git a/apps/docs/src/app/[locale]/(main)/@breadcrumb/[...rest]/page.tsx b/apps/docs/src/app/[locale]/(main)/@breadcrumb/[...rest]/page.tsx index 4d61cbd1a..e66aa3f96 100644 --- a/apps/docs/src/app/[locale]/(main)/@breadcrumb/[...rest]/page.tsx +++ b/apps/docs/src/app/[locale]/(main)/@breadcrumb/[...rest]/page.tsx @@ -1,17 +1,3 @@ -import { BreadcrumbMain } from "@vitnode/core/views/breadcrumb/breadcrumb-main"; - -// Generic catch-all breadcrumb for public pages: humanizes URL segments. -// Specific slot folders (login, register, …) override it with translated labels. -// -// NOTE: must live at the `@breadcrumb` slot ROOT (not under a `(plugins)` route -// group) — a parallel-route slot only matches `children` sharing its route-group -// structure, so a nested catch-all would miss pages outside that group. -export default async function BreadcrumbSlot({ - params, -}: { - params: Promise<{ rest?: string[] }>; -}) { - const { rest } = await params; - - return ; +export default function BreadcrumbSlot() { + return null; } diff --git a/apps/docs/src/app/[locale]/(main)/@breadcrumb/settings/overview/page.tsx b/apps/docs/src/app/[locale]/(main)/@breadcrumb/settings/overview/page.tsx new file mode 100644 index 000000000..16b68de9d --- /dev/null +++ b/apps/docs/src/app/[locale]/(main)/@breadcrumb/settings/overview/page.tsx @@ -0,0 +1,17 @@ +import { getTranslations } from "next-intl/server"; + +import { BreadcrumbMain } from "@vitnode/core/views/breadcrumb/breadcrumb-main"; + +export default async function BreadcrumbSlot() { + const t = await getTranslations("core.auth.settings"); + + return ( + + ); +} diff --git a/apps/docs/src/app/[locale]/(main)/@breadcrumb/settings/page.tsx b/apps/docs/src/app/[locale]/(main)/@breadcrumb/settings/page.tsx new file mode 100644 index 000000000..173e74e29 --- /dev/null +++ b/apps/docs/src/app/[locale]/(main)/@breadcrumb/settings/page.tsx @@ -0,0 +1,14 @@ +import { getTranslations } from "next-intl/server"; + +import { BreadcrumbMain } from "@vitnode/core/views/breadcrumb/breadcrumb-main"; + +export default async function BreadcrumbSlot() { + const t = await getTranslations("core.auth.settings"); + + return ( + + ); +} diff --git a/apps/docs/src/app/[locale]/(main)/@breadcrumb/settings/security/page.tsx b/apps/docs/src/app/[locale]/(main)/@breadcrumb/settings/security/page.tsx new file mode 100644 index 000000000..8d51c09e9 --- /dev/null +++ b/apps/docs/src/app/[locale]/(main)/@breadcrumb/settings/security/page.tsx @@ -0,0 +1,17 @@ +import { getTranslations } from "next-intl/server"; + +import { BreadcrumbMain } from "@vitnode/core/views/breadcrumb/breadcrumb-main"; + +export default async function BreadcrumbSlot() { + const t = await getTranslations("core.auth.settings"); + + return ( + + ); +} diff --git a/apps/docs/src/examples/accordion.tsx b/apps/docs/src/examples/accordion.tsx index f5b2658ca..6c0f1a22f 100644 --- a/apps/docs/src/examples/accordion.tsx +++ b/apps/docs/src/examples/accordion.tsx @@ -9,12 +9,7 @@ import { export default function AccordionExample() { return ( - + Product Information diff --git a/apps/docs/src/examples/alert-dialog.tsx b/apps/docs/src/examples/alert-dialog.tsx new file mode 100644 index 000000000..ea5151ee9 --- /dev/null +++ b/apps/docs/src/examples/alert-dialog.tsx @@ -0,0 +1,37 @@ +"use client"; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@vitnode/core/components/ui/alert-dialog"; +import { Button } from "@vitnode/core/components/ui/button"; + +export default function AlertDialogExample() { + return ( + + Show Dialog} + /> + + + Are you absolutely sure? + + This action cannot be undone. This will permanently delete your + account and remove your data from our servers. + + + + Cancel + Continue + + + + ); +} diff --git a/apps/docs/src/examples/dialog.tsx b/apps/docs/src/examples/dialog.tsx new file mode 100644 index 000000000..586f8ced1 --- /dev/null +++ b/apps/docs/src/examples/dialog.tsx @@ -0,0 +1,34 @@ +"use client"; + +import { Button } from "@vitnode/core/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@vitnode/core/components/ui/dialog"; + +export default function DialogDemo() { + return ( + + Open} /> + + + Are you absolutely sure? + + This action cannot be undone. This will permanently delete your + account and remove your data from our servers. + + + + Cancel} /> + + + + + ); +} diff --git a/apps/docs/src/examples/dropdown-menu.tsx b/apps/docs/src/examples/dropdown-menu.tsx index f6178c625..e470a5ab7 100644 --- a/apps/docs/src/examples/dropdown-menu.tsx +++ b/apps/docs/src/examples/dropdown-menu.tsx @@ -7,7 +7,6 @@ import { DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, - DropdownMenuPortal, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuSub, @@ -19,8 +18,8 @@ import { export default function DropdownMenuExample() { return ( - - + }> + Open My Account @@ -47,14 +46,12 @@ export default function DropdownMenuExample() { Team Invite users - - - Email - Message - - More... - - + + Email + Message + + More... + New Team diff --git a/apps/docs/src/examples/popover.tsx b/apps/docs/src/examples/popover.tsx index a02b3ff55..1b174e1bd 100644 --- a/apps/docs/src/examples/popover.tsx +++ b/apps/docs/src/examples/popover.tsx @@ -10,9 +10,7 @@ import { export default function PopoverDemo() { return ( - - - + Open} /> Place content for the popover here. ); diff --git a/apps/docs/src/examples/separator.tsx b/apps/docs/src/examples/separator.tsx index f5221bc5c..05bb609ab 100644 --- a/apps/docs/src/examples/separator.tsx +++ b/apps/docs/src/examples/separator.tsx @@ -4,7 +4,7 @@ export default function ProgressDemo() { return (
-

Radix Primitives

+

Base UI

An open-source UI component library.

diff --git a/apps/docs/src/examples/sheet.tsx b/apps/docs/src/examples/sheet.tsx index e2dc6c7fa..056a926c7 100644 --- a/apps/docs/src/examples/sheet.tsx +++ b/apps/docs/src/examples/sheet.tsx @@ -15,9 +15,7 @@ import { export default function SheetDemo() { return ( - - - + Open} /> Are you absolutely sure? @@ -28,9 +26,7 @@ export default function SheetDemo() { - - - + Cancel} /> diff --git a/apps/docs/src/locales/@vitnode/core/en.json b/apps/docs/src/locales/@vitnode/core/en.json index ba4d07844..f84da55e7 100644 --- a/apps/docs/src/locales/@vitnode/core/en.json +++ b/apps/docs/src/locales/@vitnode/core/en.json @@ -99,8 +99,11 @@ } }, "user_bar": { + "my_profile": "My Profile", "log_out": "Log out", - "admin_cp": "Admin CP" + "mod_cp": "Moderator CP", + "admin_cp": "Admin CP", + "settings": "Settings" } }, "auth": { @@ -204,6 +207,14 @@ "title": "Password changed successfully", "desc": "You can now log in with your new password." } + }, + "settings": { + "title": "Settings", + "desc": "Manage your profile, security, and account preferences.", + "nav": { + "overview": "Overview", + "security": "Security" + } } } }, @@ -264,8 +275,9 @@ "user": "User", "createdAt": "Created At", "emailNotVerified": "Email Not Verified", - "view": "View profile", + "edit": "Edit profile", "roles": "Roles", + "email": "Email", "searchPlaceholder": "Search users by email or username...", "noResults": { "title": "No users found", @@ -276,7 +288,26 @@ "title": "User Profile", "joined": "Joined", "emailNotVerified": "Email Not Verified", - "coverPlaceholder": "No cover image" + "coverPlaceholder": "No cover image", + "editCover": "Edit cover image", + "editAvatar": "Edit avatar", + "editName": "Edit username", + "editEmail": "Edit email", + "uploadImage": "Upload image", + "removeImage": "Remove image", + "goToProfile": "Go to Public Profile", + "updateSuccess": "Profile updated.", + "editNameCode": "Edit name code", + "editNameCodeDesc": "The name code is the unique handle used in this user's profile URL and @mentions.", + "editNameCodeWarningTitle": "This change breaks existing links", + "editNameCodeWarning": "Changing the name code will break existing @mention links and change the public profile URL. Anyone visiting the old link will no longer reach this profile.", + "newNameCode": "New name code", + "confirmNameCode": "Type the current name code () to confirm", + "nameCodeInvalid": "Only letters, numbers, and hyphens are allowed.", + "nameCodeSame": "The new name code must be different from the current one.", + "nameCodeConfirmMismatch": "This does not match the current name code.", + "nameCodeExists": "This name code is already taken.", + "saveNameCode": "Change name code" }, "verify_email": { "label": "Verify Email", diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/eslint-react/.prettierrc.mjs b/packages/create-vitnode-app/copy-of-vitnode-app/eslint-react/.prettierrc.mjs new file mode 100644 index 000000000..d3fc42bb4 --- /dev/null +++ b/packages/create-vitnode-app/copy-of-vitnode-app/eslint-react/.prettierrc.mjs @@ -0,0 +1,11 @@ +import vitnodePrettier from "@vitnode/config/prettierrc"; + +/** + * @see https://prettier.io/docs/en/configuration.html + * @type {import("prettier").Config} + */ +const config = { + ...vitnodePrettier, +}; + +export default config; diff --git a/packages/vitnode/src/api/lib/plugin.ts b/packages/vitnode/src/api/lib/plugin.ts index 8c27aaf76..3f256cb5f 100644 --- a/packages/vitnode/src/api/lib/plugin.ts +++ b/packages/vitnode/src/api/lib/plugin.ts @@ -7,10 +7,10 @@ import type { WebSocketConfig } from "./websocket"; import { checkPluginId } from "./check-plugin-id"; export interface BuildPluginApiReturn { - cronJobs: Omit[]; + cronJobs?: Omit[]; hono: OpenAPIHono; pluginId: string; - webSockets: Omit[]; + webSockets?: Omit[]; } export function buildApiPlugin

({ diff --git a/packages/vitnode/src/api/middlewares/global.middleware.ts b/packages/vitnode/src/api/middlewares/global.middleware.ts index 9c7bc2b01..229c70ff3 100644 --- a/packages/vitnode/src/api/middlewares/global.middleware.ts +++ b/packages/vitnode/src/api/middlewares/global.middleware.ts @@ -114,7 +114,7 @@ export const globalMiddleware = ({ })); const cronMetadata = plugins.flatMap(plugin => - plugin.cronJobs.map(cronJob => ({ + (plugin.cronJobs ?? []).map(cronJob => ({ pluginId: plugin.pluginId, module: cronJob.module, name: cronJob.name, @@ -125,7 +125,7 @@ export const globalMiddleware = ({ ); const webSocketsMetadata: WebSocketConfig[] = plugins.flatMap(plugin => - plugin.webSockets.map(webSocket => ({ + (plugin.webSockets ?? []).map(webSocket => ({ ...webSocket, pluginId: plugin.pluginId, })), diff --git a/packages/vitnode/src/api/modules/admin/users/routes/list.route.ts b/packages/vitnode/src/api/modules/admin/users/routes/list.route.ts index 9daded584..ef9e5735c 100644 --- a/packages/vitnode/src/api/modules/admin/users/routes/list.route.ts +++ b/packages/vitnode/src/api/modules/admin/users/routes/list.route.ts @@ -1,5 +1,5 @@ import { z } from "@hono/zod-openapi"; -import { inArray } from "drizzle-orm"; +import { and, eq, inArray } from "drizzle-orm"; import { buildRoute } from "@/api/lib/route"; import { @@ -8,6 +8,8 @@ import { zodPaginationQuery, } from "@/api/lib/with-pagination"; import { CONFIG_PLUGIN } from "@/config"; +import { core_languages_words } from "@/database/languages"; +import { core_roles } from "@/database/roles"; import { core_users } from "@/database/users"; export const listUsersAdminRoute = buildRoute({ @@ -40,6 +42,16 @@ export const listUsersAdminRoute = buildRoute({ avatarColor: z.string(), emailVerified: z.boolean(), roleId: z.number(), + role: z.object({ + id: z.number(), + color: z.string().nullable(), + name: z.array( + z.object({ + name: z.string(), + languageCode: z.string(), + }), + ), + }), birthday: z.date().nullable(), language: z.string(), }), @@ -78,10 +90,12 @@ export const listUsersAdminRoute = buildRoute({ avatarColor: core_users.avatarColor, emailVerified: core_users.emailVerified, roleId: core_users.roleId, + roleColor: core_roles.color, birthday: core_users.birthday, language: core_users.language, }) .from(core_users) + .leftJoin(core_roles, eq(core_roles.id, core_users.roleId)) .where(where) .orderBy(orderBy) .limit(limit), @@ -95,6 +109,43 @@ export const listUsersAdminRoute = buildRoute({ c, }); - return c.json(data); + // Role names live in `core_languages_words`, so resolve the translations + // for every role referenced by the listed users in a single query. + const userRoleIds = [...new Set(data.edges.map(user => user.roleId))]; + const roleNames = userRoleIds.length + ? await c + .get("db") + .select({ + itemId: core_languages_words.itemId, + languageCode: core_languages_words.languageCode, + value: core_languages_words.value, + }) + .from(core_languages_words) + .where( + and( + eq(core_languages_words.tableName, "core_roles"), + eq(core_languages_words.variable, "name"), + eq(core_languages_words.pluginCode, "core"), + inArray(core_languages_words.itemId, userRoleIds), + ), + ) + : []; + + return c.json({ + pageInfo: data.pageInfo, + edges: data.edges.map(({ roleColor, ...user }) => ({ + ...user, + role: { + id: user.roleId, + color: roleColor, + name: roleNames + .filter(word => word.itemId === user.roleId) + .map(word => ({ + name: word.value, + languageCode: word.languageCode, + })), + }, + })), + }); }, }); diff --git a/packages/vitnode/src/api/modules/admin/users/routes/update.route.ts b/packages/vitnode/src/api/modules/admin/users/routes/update.route.ts new file mode 100644 index 000000000..96086a1a3 --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/users/routes/update.route.ts @@ -0,0 +1,175 @@ +import { z } from "@hono/zod-openapi"; +import { and, eq, ne } from "drizzle-orm"; + +import { buildRoute } from "@/api/lib/route"; +import { CONFIG_PLUGIN } from "@/config"; +import { core_users } from "@/database/users"; + +const nameRegex = /^(?!.* {2})[\p{L}\p{N}._@ -]*$/u; + +export const zodUpdateUserAdminSchema = z + .object({ + email: z.email().toLowerCase().openapi({ + example: "test@test.com", + }), + name: z + .string() + .min(3) + .refine(val => nameRegex.test(val), { + message: "Invalid name", + }) + .openapi({ example: "test" }), + nameCode: z + .string() + .min(3) + .max(255) + .regex(/^[a-zA-Z0-9-]+$/, { message: "Invalid name code" }) + .openapi({ example: "test" }), + }) + .partial() + .refine( + body => + body.email !== undefined || + body.name !== undefined || + body.nameCode !== undefined, + { + message: "At least one field is required", + }, + ); + +export const updateUserAdminRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + route: { + method: "patch", + description: "Update a user's name or email by name SEO (Admin only)", + path: "/{nameCode}", + request: { + params: z.object({ + nameCode: z.string().openapi({ example: "test" }), + }), + body: { + required: true, + content: { + "application/json": { + schema: zodUpdateUserAdminSchema, + }, + }, + }, + }, + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ + id: z.number(), + name: z.string(), + email: z.email(), + nameCode: z.string(), + }), + }, + }, + description: "User updated", + }, + 403: { + description: "Access Denied", + }, + 404: { + content: { + "application/json": { + schema: z.object({ error: z.string() }), + }, + }, + description: "User not found", + }, + 409: { + content: { + "application/json": { + schema: z.object({ error: z.string() }), + }, + }, + description: "Email or name already exists", + }, + }, + }, + handler: async c => { + const { nameCode } = c.req.valid("param"); + const body = c.req.valid("json"); + const db = c.get("db"); + + const [user] = await db + .select({ id: core_users.id }) + .from(core_users) + .where(eq(core_users.nameCode, nameCode)) + .limit(1); + + if (!user) { + return c.json({ error: "User not found" }, 404); + } + + const values: Partial = {}; + + if (body.email !== undefined) { + const [existing] = await db + .select({ id: core_users.id }) + .from(core_users) + .where( + and(eq(core_users.email, body.email), ne(core_users.id, user.id)), + ) + .limit(1); + + if (existing) { + return c.json({ error: "Email already exists" }, 409); + } + + values.email = body.email; + } + + if (body.name !== undefined) { + const [existing] = await db + .select({ id: core_users.id }) + .from(core_users) + .where(and(eq(core_users.name, body.name), ne(core_users.id, user.id))) + .limit(1); + + if (existing) { + return c.json({ error: "Name already exists" }, 409); + } + + values.name = body.name; + } + + if (body.nameCode !== undefined) { + // The name code is the user's URL identifier (`@mention` handle), so it + // must stay globally unique. + const [existing] = await db + .select({ id: core_users.id }) + .from(core_users) + .where( + and( + eq(core_users.nameCode, body.nameCode), + ne(core_users.id, user.id), + ), + ) + .limit(1); + + if (existing) { + return c.json({ error: "Name code already exists" }, 409); + } + + values.nameCode = body.nameCode; + } + + const [updated] = await db + .update(core_users) + .set(values) + .where(eq(core_users.id, user.id)) + .returning({ + id: core_users.id, + name: core_users.name, + email: core_users.email, + nameCode: core_users.nameCode, + }); + + return c.json(updated, 200); + }, +}); diff --git a/packages/vitnode/src/api/modules/admin/users/users.admin.module.ts b/packages/vitnode/src/api/modules/admin/users/users.admin.module.ts index 34cf4f2e4..b5f77c6da 100644 --- a/packages/vitnode/src/api/modules/admin/users/users.admin.module.ts +++ b/packages/vitnode/src/api/modules/admin/users/users.admin.module.ts @@ -4,6 +4,7 @@ import { CONFIG_PLUGIN } from "@/config"; import { createUserAdminRoute } from "./routes/create.route"; import { listUsersAdminRoute } from "./routes/list.route"; import { showUserAdminRoute } from "./routes/show.route"; +import { updateUserAdminRoute } from "./routes/update.route"; import { verifyEmailUserAdminRoute } from "./routes/verify-email.route"; export const usersAdminModule = buildModule({ @@ -13,6 +14,7 @@ export const usersAdminModule = buildModule({ listUsersAdminRoute, createUserAdminRoute, showUserAdminRoute, + updateUserAdminRoute, verifyEmailUserAdminRoute, ], }); diff --git a/packages/vitnode/src/api/modules/users/routes/session.route.ts b/packages/vitnode/src/api/modules/users/routes/session.route.ts index 8a6d9b706..d97517012 100644 --- a/packages/vitnode/src/api/modules/users/routes/session.route.ts +++ b/packages/vitnode/src/api/modules/users/routes/session.route.ts @@ -28,6 +28,7 @@ export const sessionRoute = buildRoute({ roleId: z.number(), birthday: z.date().nullable(), isAdmin: z.boolean(), + isModerator: z.boolean(), }) .nullable(), }), @@ -46,6 +47,7 @@ export const sessionRoute = buildRoute({ ? { ...user, isAdmin: await admin.checkIfUserIsAdmin(user.id), + isModerator: false, // TODO: implement moderator role } : null, }); diff --git a/packages/vitnode/src/components/confirm-action/confirm-action-alert-dialog.tsx b/packages/vitnode/src/components/confirm-action/confirm-action-alert-dialog.tsx index 9861c3608..b8ad44b19 100644 --- a/packages/vitnode/src/components/confirm-action/confirm-action-alert-dialog.tsx +++ b/packages/vitnode/src/components/confirm-action/confirm-action-alert-dialog.tsx @@ -29,7 +29,7 @@ export const ConfirmActionAlertDialog = ({ ...props }: Omit, "children"> & React.ComponentProps & { - children: React.ReactNode; + children: React.ReactElement; description?: React.ReactNode; title?: React.ReactNode; }) => { @@ -37,7 +37,7 @@ export const ConfirmActionAlertDialog = ({ return ( - {children} + diff --git a/packages/vitnode/src/components/form/auto-form.tsx b/packages/vitnode/src/components/form/auto-form.tsx index 16ab51913..5adfabd77 100644 --- a/packages/vitnode/src/components/form/auto-form.tsx +++ b/packages/vitnode/src/components/form/auto-form.tsx @@ -227,9 +227,9 @@ export function AutoForm< {captcha &&

} {setIsDirty ? ( - - - + {t("cancel")}} + /> {submitButton} ) : ( diff --git a/packages/vitnode/src/components/form/fields/checkbox.tsx b/packages/vitnode/src/components/form/fields/checkbox.tsx index 54e23ca74..10bcfb5b0 100644 --- a/packages/vitnode/src/components/form/fields/checkbox.tsx +++ b/packages/vitnode/src/components/form/fields/checkbox.tsx @@ -29,9 +29,9 @@ export const AutoFormCheckbox = ({ { - field.onChange(e); - props.onCheckedChange?.(e); + onCheckedChange={(checked, eventDetails) => { + field.onChange(checked); + props.onCheckedChange?.(checked, eventDetails); }} {...field} {...props} diff --git a/packages/vitnode/src/components/form/fields/select.tsx b/packages/vitnode/src/components/form/fields/select.tsx index 204153042..9e1bd6575 100644 --- a/packages/vitnode/src/components/form/fields/select.tsx +++ b/packages/vitnode/src/components/form/fields/select.tsx @@ -42,10 +42,6 @@ export const AutoFormSelect = ({ }; }); - const currentPlaceholder = - (values ?? labels).find(l => l.value === field.value)?.label ?? - t("select_option"); - return ( <> {label && ( @@ -57,20 +53,15 @@ export const AutoFormSelect = ({ { + if (value == null) { + return; + } startTransition(() => { const params = new URLSearchParams(searchParams.toString()); - params.set("first", value); + params.set("first", value as string); params.delete("last"); params.delete("cursor"); push(`${pathname}?${params.toString()}`, { diff --git a/packages/vitnode/src/components/tiptap/toolbar/actions/alignment-action.tsx b/packages/vitnode/src/components/tiptap/toolbar/actions/alignment-action.tsx index 7b4ddcaa3..e796b7862 100644 --- a/packages/vitnode/src/components/tiptap/toolbar/actions/alignment-action.tsx +++ b/packages/vitnode/src/components/tiptap/toolbar/actions/alignment-action.tsx @@ -65,13 +65,15 @@ export const AlignmentAction = () => { return ( - - + diff --git a/packages/vitnode/src/components/tiptap/toolbar/actions/headings-action.tsx b/packages/vitnode/src/components/tiptap/toolbar/actions/headings-action.tsx index d35ac62f9..86a4ba626 100644 --- a/packages/vitnode/src/components/tiptap/toolbar/actions/headings-action.tsx +++ b/packages/vitnode/src/components/tiptap/toolbar/actions/headings-action.tsx @@ -62,13 +62,15 @@ export const HeadingsAction = () => { return ( - - + diff --git a/packages/vitnode/src/components/tiptap/toolbar/actions/text-format-more/text-format-more.tsx b/packages/vitnode/src/components/tiptap/toolbar/actions/text-format-more/text-format-more.tsx index 7dcb9c265..0b3c274be 100644 --- a/packages/vitnode/src/components/tiptap/toolbar/actions/text-format-more/text-format-more.tsx +++ b/packages/vitnode/src/components/tiptap/toolbar/actions/text-format-more/text-format-more.tsx @@ -29,17 +29,19 @@ export const TextFormatMore = () => { return ( - - + + } + > + diff --git a/packages/vitnode/src/components/ui/accordion.tsx b/packages/vitnode/src/components/ui/accordion.tsx index 4aaf7ad51..2753fc0aa 100644 --- a/packages/vitnode/src/components/ui/accordion.tsx +++ b/packages/vitnode/src/components/ui/accordion.tsx @@ -1,7 +1,7 @@ "use client"; +import { Accordion as AccordionPrimitive } from "@base-ui/react/accordion"; import { ChevronDownIcon, ChevronUpIcon } from "lucide-react"; -import { Accordion as AccordionPrimitive } from "radix-ui"; import * as React from "react"; import { cn } from "@/lib/utils"; @@ -49,11 +49,11 @@ function AccordionTrigger({ > {children} @@ -65,22 +65,22 @@ function AccordionContent({ className, children, ...props -}: React.ComponentProps) { +}: React.ComponentProps) { return ( -
{children}
-
+ ); } diff --git a/packages/vitnode/src/components/ui/alert-dialog.tsx b/packages/vitnode/src/components/ui/alert-dialog.tsx index 1218b04c1..bd653acf3 100644 --- a/packages/vitnode/src/components/ui/alert-dialog.tsx +++ b/packages/vitnode/src/components/ui/alert-dialog.tsx @@ -1,8 +1,8 @@ "use client"; -import { AlertDialog as AlertDialogPrimitive } from "radix-ui"; +import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog"; +import { useTranslations } from "next-intl"; import React from "react"; -import { useTranslations } from "use-intl"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; @@ -23,11 +23,14 @@ function AlertDialog({ onOpenChange, open: openProp, ...props -}: React.ComponentProps) { +}: AlertDialogPrimitive.Root.Props) { const [open, setOpen] = React.useState(false); - const handleOpenChange = (newOpen: boolean) => { - onOpenChange?.(newOpen); + const handleOpenChange: AlertDialogPrimitive.Root.Props["onOpenChange"] = ( + newOpen, + eventDetails, + ) => { + onOpenChange?.(newOpen, eventDetails); setOpen(newOpen); }; @@ -45,17 +48,13 @@ function AlertDialog({ ); } -function AlertDialogTrigger({ - ...props -}: React.ComponentProps) { +function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) { return ( ); } -function AlertDialogPortal({ - ...props -}: React.ComponentProps) { +function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) { return ( ); @@ -64,11 +63,11 @@ function AlertDialogPortal({ function AlertDialogOverlay({ className, ...props -}: React.ComponentProps) { +}: AlertDialogPrimitive.Backdrop.Props) { return ( - ) { +}: AlertDialogPrimitive.Popup.Props) { return ( - ) { +}: AlertDialogPrimitive.Title.Props) { return ( ) { +}: AlertDialogPrimitive.Description.Props) { return ( , "size" | "variant"> & - React.ComponentProps) { +}: AlertDialogPrimitive.Close.Props & + Pick, "size" | "variant">) { const t = useTranslations("core.global"); return ( - + + } + {...props} + /> ); } @@ -219,18 +219,17 @@ function AlertDialogCancel({ variant = "outline", size = "default", ...props -}: Pick, "size" | "variant"> & - React.ComponentProps) { +}: AlertDialogPrimitive.Close.Props & + Pick, "size" | "variant">) { const t = useTranslations("core.global"); return ( - + } + {...props} + /> ); } diff --git a/packages/vitnode/src/components/ui/aspect-ratio.tsx b/packages/vitnode/src/components/ui/aspect-ratio.tsx index 41720ca39..f9c1f4bbc 100644 --- a/packages/vitnode/src/components/ui/aspect-ratio.tsx +++ b/packages/vitnode/src/components/ui/aspect-ratio.tsx @@ -1,11 +1,19 @@ -"use client"; - -import { AspectRatio as AspectRatioPrimitive } from "radix-ui"; +import * as React from "react"; function AspectRatio({ + ratio = 1, + style, ...props -}: React.ComponentProps) { - return ; +}: React.ComponentProps<"div"> & { + ratio?: number; +}) { + return ( +
+ ); } export { AspectRatio }; diff --git a/packages/vitnode/src/components/ui/badge.tsx b/packages/vitnode/src/components/ui/badge.tsx index 6754970b8..bdcb00e53 100644 --- a/packages/vitnode/src/components/ui/badge.tsx +++ b/packages/vitnode/src/components/ui/badge.tsx @@ -1,6 +1,6 @@ +import { mergeProps } from "@base-ui/react/merge-props"; +import { useRender } from "@base-ui/react/use-render"; import { cva, type VariantProps } from "class-variance-authority"; -import { Slot } from "radix-ui"; -import * as React from "react"; import { cn } from "@/lib/utils"; @@ -30,20 +30,23 @@ const badgeVariants = cva( function Badge({ className, variant = "default", - asChild = false, + render, ...props -}: React.ComponentProps<"span"> & - VariantProps & { asChild?: boolean }) { - const Comp = asChild ? Slot.Root : "span"; - - return ( - - ); +}: useRender.ComponentProps<"span"> & VariantProps) { + return useRender({ + defaultTagName: "span", + props: mergeProps<"span">( + { + className: cn(badgeVariants({ variant }), className), + }, + props, + ), + render, + state: { + slot: "badge", + variant, + }, + }); } export { Badge, badgeVariants }; diff --git a/packages/vitnode/src/components/ui/breadcrumb.tsx b/packages/vitnode/src/components/ui/breadcrumb.tsx index e3a023373..d35587288 100644 --- a/packages/vitnode/src/components/ui/breadcrumb.tsx +++ b/packages/vitnode/src/components/ui/breadcrumb.tsx @@ -1,5 +1,6 @@ +import { mergeProps } from "@base-ui/react/merge-props"; +import { useRender } from "@base-ui/react/use-render"; import { ChevronRightIcon, MoreHorizontalIcon } from "lucide-react"; -import { Slot } from "radix-ui"; import * as React from "react"; import { cn } from "@/lib/utils"; @@ -39,21 +40,23 @@ function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) { } function BreadcrumbLink({ - asChild, className, + render, ...props -}: React.ComponentProps<"a"> & { - asChild?: boolean; -}) { - const Comp = asChild ? Slot.Root : "a"; - - return ( - - ); +}: useRender.ComponentProps<"a">) { + return useRender({ + defaultTagName: "a", + props: mergeProps<"a">( + { + className: cn("hover:text-foreground transition-colors", className), + }, + props, + ), + render, + state: { + slot: "breadcrumb-link", + }, + }); } function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) { diff --git a/packages/vitnode/src/components/ui/button-client.tsx b/packages/vitnode/src/components/ui/button-client.tsx index 6718a059f..3f524217e 100644 --- a/packages/vitnode/src/components/ui/button-client.tsx +++ b/packages/vitnode/src/components/ui/button-client.tsx @@ -1,8 +1,8 @@ "use client"; +import { Button as ButtonPrimitive } from "@base-ui/react/button"; import { AnimatePresence, motion } from "motion/react"; import { useTranslations } from "next-intl"; -import { Slot } from "radix-ui"; import { cn } from "../../lib/utils"; import { type ButtonProps, buttonVariants } from "./button"; @@ -12,16 +12,14 @@ export function ClientButton({ className, variant, size, - asChild = false, isLoading, children, ...props }: ButtonProps) { - const Comp = asChild ? Slot.Root : "button"; const t = useTranslations("core.global"); return ( -
)} - + ); } diff --git a/packages/vitnode/src/components/ui/button-group.tsx b/packages/vitnode/src/components/ui/button-group.tsx index e46e7a49e..575c4ea6d 100644 --- a/packages/vitnode/src/components/ui/button-group.tsx +++ b/packages/vitnode/src/components/ui/button-group.tsx @@ -1,5 +1,6 @@ +import { mergeProps } from "@base-ui/react/merge-props"; +import { useRender } from "@base-ui/react/use-render"; import { cva, type VariantProps } from "class-variance-authority"; -import { Slot } from "radix-ui"; import { Separator } from "@/components/ui/separator"; import { cn } from "@/lib/utils"; @@ -39,22 +40,25 @@ function ButtonGroup({ function ButtonGroupText({ className, - asChild = false, + render, ...props -}: React.ComponentProps<"div"> & { - asChild?: boolean; -}) { - const Comp = asChild ? Slot.Root : "div"; - - return ( - - ); +}: useRender.ComponentProps<"div">) { + return useRender({ + defaultTagName: "div", + props: mergeProps<"div">( + { + className: cn( + "bg-muted flex items-center gap-2 rounded-md border px-2.5 text-sm font-medium shadow-xs [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4", + className, + ), + }, + props, + ), + render, + state: { + slot: "button-group-text", + }, + }); } function ButtonGroupSeparator({ diff --git a/packages/vitnode/src/components/ui/button.tsx b/packages/vitnode/src/components/ui/button.tsx index a36ade806..b6e51ca77 100644 --- a/packages/vitnode/src/components/ui/button.tsx +++ b/packages/vitnode/src/components/ui/button.tsx @@ -1,4 +1,4 @@ -import type * as React from "react"; +import type { Button as ButtonPrimitive } from "@base-ui/react/button"; import { cva, type VariantProps } from "class-variance-authority"; @@ -41,13 +41,13 @@ const buttonVariants = cva( }, ); -type ButtonProps = React.ComponentProps<"button"> & +type ButtonProps = Omit & VariantProps & ( | { "aria-label": string; size: "icon" | "icon-lg" | "icon-sm" | "icon-xs" } | { "aria-label"?: string; size?: "default" | "lg" | "sm" } ) & { - asChild?: boolean; + className?: string; isLoading?: boolean; }; diff --git a/packages/vitnode/src/components/ui/checkbox.tsx b/packages/vitnode/src/components/ui/checkbox.tsx index 029abd8bf..a7685c07a 100644 --- a/packages/vitnode/src/components/ui/checkbox.tsx +++ b/packages/vitnode/src/components/ui/checkbox.tsx @@ -1,7 +1,7 @@ "use client"; +import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox"; import { CheckIcon } from "lucide-react"; -import { Checkbox as CheckboxPrimitive } from "radix-ui"; import * as React from "react"; import { cn } from "@/lib/utils"; diff --git a/packages/vitnode/src/components/ui/collapsible.tsx b/packages/vitnode/src/components/ui/collapsible.tsx index 3a98064e7..d5846f686 100644 --- a/packages/vitnode/src/components/ui/collapsible.tsx +++ b/packages/vitnode/src/components/ui/collapsible.tsx @@ -1,6 +1,7 @@ "use client"; -import { Collapsible as CollapsiblePrimitive } from "radix-ui"; +import { Collapsible as CollapsiblePrimitive } from "@base-ui/react/collapsible"; +import * as React from "react"; function Collapsible({ ...props @@ -10,23 +11,17 @@ function Collapsible({ function CollapsibleTrigger({ ...props -}: React.ComponentProps) { +}: React.ComponentProps) { return ( - + ); } function CollapsibleContent({ ...props -}: React.ComponentProps) { +}: React.ComponentProps) { return ( - + ); } diff --git a/packages/vitnode/src/components/ui/combobox.tsx b/packages/vitnode/src/components/ui/combobox.tsx index 7f898fbba..e0221d380 100644 --- a/packages/vitnode/src/components/ui/combobox.tsx +++ b/packages/vitnode/src/components/ui/combobox.tsx @@ -70,15 +70,13 @@ function ComboboxInput({ {showTrigger && ( } size="icon-xs" variant="ghost" - > - - + /> )} {showClear && } diff --git a/packages/vitnode/src/components/ui/dialog.tsx b/packages/vitnode/src/components/ui/dialog.tsx index 8a7128657..d22f78010 100644 --- a/packages/vitnode/src/components/ui/dialog.tsx +++ b/packages/vitnode/src/components/ui/dialog.tsx @@ -1,9 +1,9 @@ "use client"; +import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"; import { XIcon } from "lucide-react"; import { useTranslations } from "next-intl"; -import { Dialog as DialogPrimitive } from "radix-ui"; -import React from "react"; +import * as React from "react"; import { cn } from "@/lib/utils"; @@ -38,14 +38,20 @@ function Dialog({ onOpenChange, open: openProp, ...props -}: React.ComponentProps) { +}: Omit & { + children?: React.ReactNode; + onOpenChange?: (open: boolean) => void; +}) { const t = useTranslations("core.global"); const [open, setOpen] = React.useState(false); const [isDirty, setIsDirty] = React.useState(false); const [openAlertDialogBeforeClose, setOpenAlertDialogBeforeClose] = React.useState(false); - const handleOpenChange = React.useCallback( + const isOpen = openProp ?? open; + + // Opens/closes the dialog programmatically, bypassing the unsaved-changes guard. + const changeOpen = React.useCallback( (newOpen: boolean) => { onOpenChange?.(newOpen); setOpen(newOpen); @@ -53,15 +59,44 @@ function Dialog({ [onOpenChange], ); + // Wired to Base UI. Blocks dismissals while there are unsaved changes (asking + // the user to confirm first) and ignores presses that land on a toast. + const handleOpenChange = ( + newOpen: boolean, + eventDetails: DialogPrimitive.Root.ChangeEventDetails, + ) => { + if (!newOpen) { + // Prevent closing the dialog if there are unsaved changes. + if (isDirty) { + eventDetails.cancel(); + setOpenAlertDialogBeforeClose(true); + + return; + } + + // Prevent dismissing the dialog when clicking on a toast. + if (eventDetails.reason === "outside-press") { + const target = eventDetails.event.target as Element | null; + if (target?.closest("[data-sonner-toaster]")) { + eventDetails.cancel(); + + return; + } + } + } + + changeOpen(newOpen); + }; + const contextValue = React.useMemo( () => ({ - open: openProp ?? open, - setOpen: handleOpenChange, + open: isOpen, + setOpen: changeOpen, isDirty, setIsDirty, setOpenAlertDialogBeforeClose, }), - [openProp, open, handleOpenChange, isDirty], + [isOpen, changeOpen, isDirty], ); return ( @@ -69,7 +104,7 @@ function Dialog({ @@ -93,7 +128,7 @@ function Dialog({ { setIsDirty(false); - setTimeout(() => setOpen(false), 100); + setTimeout(() => changeOpen(false), 100); }} > {t("are_you_sure_want_to_leave_form.confirm")} @@ -105,49 +140,26 @@ function Dialog({ ); } -function DialogTrigger({ - ...props -}: React.ComponentProps) { +function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) { return ; } -function DialogPortal({ - ...props -}: React.ComponentProps) { +function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) { return ; } -function DialogClose({ - ...props -}: React.ComponentProps) { - const { setOpenAlertDialogBeforeClose, isDirty } = useDialog(); - - return ( - { - props?.onClick?.(e); - // Prevent closing the dialog if there are unsaved changes - if (isDirty) { - e.preventDefault(); - setOpenAlertDialogBeforeClose?.(true); - - return; - } - }} - {...props} - /> - ); +function DialogClose({ ...props }: DialogPrimitive.Close.Props) { + return ; } function DialogOverlay({ className, ...props -}: React.ComponentProps) { +}: DialogPrimitive.Backdrop.Props) { return ( - & { +}: DialogPrimitive.Popup.Props & { showCloseButton?: boolean; }) { - const { isDirty, setOpenAlertDialogBeforeClose } = useDialog(); const t = useTranslations("core.global"); return ( - + - { - // Prevent dismissing the dialog when clicking on a toast - const isToastItem = (e.target as Element)?.closest( - "[data-sonner-toaster]", - ); - if (isToastItem || isDirty) e.preventDefault(); - - if (props?.onInteractOutside) { - props.onInteractOutside(e); - } - }} {...props} > {children} {showCloseButton && ( - - + + } + > + )} - + ); } @@ -247,18 +238,16 @@ function DialogFooter({ > {children} {showCloseButton && ( - - - + {t("close")}} + /> )}
); } -function DialogTitle({ - className, - ...props -}: React.ComponentProps) { +function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) { return ( ) { +}: DialogPrimitive.Description.Props) { return ( & { - direction?: React.ComponentProps["dir"]; + ...props +}: Omit< + React.ComponentProps, + "direction" +> & { + dir?: "ltr" | "rtl"; + direction?: "ltr" | "rtl"; }) { return ( - + {children} - + ); } -const useDirection = Direction.useDirection; - export { DirectionProvider, useDirection }; diff --git a/packages/vitnode/src/components/ui/dropdown-menu.tsx b/packages/vitnode/src/components/ui/dropdown-menu.tsx index 923399e86..ae3cc8a5f 100644 --- a/packages/vitnode/src/components/ui/dropdown-menu.tsx +++ b/packages/vitnode/src/components/ui/dropdown-menu.tsx @@ -1,64 +1,69 @@ "use client"; +import { Menu as MenuPrimitive } from "@base-ui/react/menu"; import { CheckIcon, ChevronRightIcon } from "lucide-react"; -import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"; import * as React from "react"; import { cn } from "@/lib/utils"; function DropdownMenu({ ...props -}: React.ComponentProps) { - return ; +}: React.ComponentProps) { + return ; } function DropdownMenuPortal({ ...props -}: React.ComponentProps) { - return ( - - ); +}: React.ComponentProps) { + return ; } function DropdownMenuTrigger({ ...props -}: React.ComponentProps) { - return ( - - ); +}: React.ComponentProps) { + return ; } function DropdownMenuContent({ className, - align = "start", + side = "bottom", sideOffset = 4, + align = "start", + alignOffset = 0, + anchor, ...props -}: React.ComponentProps) { +}: Pick< + React.ComponentProps, + "align" | "alignOffset" | "anchor" | "side" | "sideOffset" +> & + React.ComponentProps) { return ( - - + - + > + + + ); } function DropdownMenuGroup({ ...props -}: React.ComponentProps) { - return ( - - ); +}: React.ComponentProps) { + return ; } function DropdownMenuItem({ @@ -66,14 +71,14 @@ function DropdownMenuItem({ inset, variant = "default", ...props -}: React.ComponentProps & { +}: React.ComponentProps & { inset?: boolean; variant?: "default" | "destructive"; }) { return ( - & { +}: React.ComponentProps & { inset?: boolean; }) { return ( - - + - + {children} - + ); } function DropdownMenuRadioGroup({ ...props -}: React.ComponentProps) { +}: React.ComponentProps) { return ( - @@ -133,13 +138,13 @@ function DropdownMenuRadioItem({ children, inset, ...props -}: React.ComponentProps & { +}: React.ComponentProps & { inset?: boolean; }) { return ( - - + - + {children} - + ); } @@ -163,11 +168,11 @@ function DropdownMenuLabel({ className, inset, ...props -}: React.ComponentProps & { +}: React.ComponentProps<"div"> & { inset?: boolean; }) { return ( - ) { +}: React.ComponentProps) { return ( - ) { - return ; +}: React.ComponentProps) { + return ; } function DropdownMenuSubTrigger({ @@ -219,13 +224,13 @@ function DropdownMenuSubTrigger({ inset, children, ...props -}: React.ComponentProps & { +}: React.ComponentProps & { inset?: boolean; }) { return ( - {children} - + ); } function DropdownMenuSubContent({ className, + side = "right", + sideOffset = 0, + align = "start", + alignOffset = 0, + anchor, ...props -}: React.ComponentProps) { +}: Pick< + React.ComponentProps, + "align" | "alignOffset" | "anchor" | "side" | "sideOffset" +> & + React.ComponentProps) { return ( - + + + + + ); } diff --git a/packages/vitnode/src/components/ui/form.tsx b/packages/vitnode/src/components/ui/form.tsx index 108dec104..4bef7f1c1 100644 --- a/packages/vitnode/src/components/ui/form.tsx +++ b/packages/vitnode/src/components/ui/form.tsx @@ -1,7 +1,8 @@ "use client"; +import { mergeProps } from "@base-ui/react/merge-props"; +import { useRender } from "@base-ui/react/use-render"; import { useTranslations } from "next-intl"; -import { Slot } from "radix-ui"; import React from "react"; import { Controller, @@ -120,20 +121,23 @@ const useFormField = () => { }; }; -function FormControl({ ...props }: React.ComponentProps) { +function FormControl({ children, ...props }: React.ComponentProps<"div">) { const { error, formItemId, formDescriptionId, formMessageId } = useFormField(); - return ( - - ); + return useRender({ + props: mergeProps<"div">( + { + "aria-describedby": error + ? `${formDescriptionId} ${formMessageId}` + : formDescriptionId, + "aria-invalid": !!error, + id: formItemId, + }, + props, + ), + render: children as React.ReactElement, + }); } function FormMessage(props: React.ComponentProps) { diff --git a/packages/vitnode/src/components/ui/hover-card.tsx b/packages/vitnode/src/components/ui/hover-card.tsx index 622edc9f3..dacac5d6d 100644 --- a/packages/vitnode/src/components/ui/hover-card.tsx +++ b/packages/vitnode/src/components/ui/hover-card.tsx @@ -1,43 +1,57 @@ "use client"; -import { HoverCard as HoverCardPrimitive } from "radix-ui"; +import { PreviewCard as PreviewCardPrimitive } from "@base-ui/react/preview-card"; import * as React from "react"; import { cn } from "@/lib/utils"; function HoverCard({ ...props -}: React.ComponentProps) { - return ; +}: React.ComponentProps) { + return ; } function HoverCardTrigger({ ...props -}: React.ComponentProps) { +}: React.ComponentProps) { return ( - + ); } function HoverCardContent({ className, - align = "center", + side, sideOffset = 4, + align = "center", + alignOffset = 0, + anchor, ...props -}: React.ComponentProps) { +}: Pick< + React.ComponentProps, + "align" | "alignOffset" | "anchor" | "side" | "sideOffset" +> & + React.ComponentProps) { return ( - - + - + > + + + ); } diff --git a/packages/vitnode/src/components/ui/item.tsx b/packages/vitnode/src/components/ui/item.tsx index 603ba6727..4ce6cd8a6 100644 --- a/packages/vitnode/src/components/ui/item.tsx +++ b/packages/vitnode/src/components/ui/item.tsx @@ -1,5 +1,6 @@ +import { mergeProps } from "@base-ui/react/merge-props"; +import { useRender } from "@base-ui/react/use-render"; import { cva, type VariantProps } from "class-variance-authority"; -import { Slot } from "radix-ui"; import * as React from "react"; import { Separator } from "@/components/ui/separator"; @@ -59,21 +60,24 @@ function Item({ className, variant = "default", size = "default", - asChild = false, + render, ...props -}: React.ComponentProps<"div"> & - VariantProps & { asChild?: boolean }) { - const Comp = asChild ? Slot.Root : "div"; - - return ( - - ); +}: useRender.ComponentProps<"div"> & VariantProps) { + return useRender({ + defaultTagName: "div", + props: mergeProps<"div">( + { + className: cn(itemVariants({ variant, size, className })), + }, + props, + ), + render, + state: { + slot: "item", + variant, + size, + }, + }); } const itemMediaVariants = cva( diff --git a/packages/vitnode/src/components/ui/label.tsx b/packages/vitnode/src/components/ui/label.tsx index 083adc609..8a85f7efe 100644 --- a/packages/vitnode/src/components/ui/label.tsx +++ b/packages/vitnode/src/components/ui/label.tsx @@ -1,16 +1,10 @@ -"use client"; - -import { Label as LabelPrimitive } from "radix-ui"; import * as React from "react"; import { cn } from "@/lib/utils"; -function Label({ - className, - ...props -}: React.ComponentProps) { +function Label({ className, ...props }: React.ComponentProps<"label">) { return ( - + } size={size} variant={isActive ? "outline" : "ghost"} - > - - + /> ); } diff --git a/packages/vitnode/src/components/ui/popover.tsx b/packages/vitnode/src/components/ui/popover.tsx index 064eb9b15..5e51f2caf 100644 --- a/packages/vitnode/src/components/ui/popover.tsx +++ b/packages/vitnode/src/components/ui/popover.tsx @@ -1,6 +1,6 @@ "use client"; -import { Popover as PopoverPrimitive } from "radix-ui"; +import { Popover as PopoverPrimitive } from "@base-ui/react/popover"; import * as React from "react"; import { cn } from "@/lib/utils"; @@ -19,32 +19,40 @@ function PopoverTrigger({ function PopoverContent({ className, - align = "center", + side, sideOffset = 4, + align = "center", + alignOffset = 0, + anchor, ...props -}: React.ComponentProps) { +}: Pick< + React.ComponentProps, + "align" | "alignOffset" | "anchor" | "side" | "sideOffset" +> & + React.ComponentProps) { return ( - + > + + ); } -function PopoverAnchor({ - ...props -}: React.ComponentProps) { - return ; -} - function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) { return (
) { ); } -function PopoverTitle({ className, ...props }: React.ComponentProps<"h2">) { +function PopoverTitle({ + className, + ...props +}: React.ComponentProps) { return ( -
) { function PopoverDescription({ className, ...props -}: React.ComponentProps<"p">) { +}: React.ComponentProps) { return ( -

) { return ( - + + + ); } diff --git a/packages/vitnode/src/components/ui/scroll-area.tsx b/packages/vitnode/src/components/ui/scroll-area.tsx index 0e5fa4c6f..f9b611878 100644 --- a/packages/vitnode/src/components/ui/scroll-area.tsx +++ b/packages/vitnode/src/components/ui/scroll-area.tsx @@ -1,6 +1,6 @@ "use client"; -import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"; +import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"; import * as React from "react"; import { cn } from "@/lib/utils"; @@ -20,7 +20,9 @@ function ScrollArea({ className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1" data-slot="scroll-area-viewport" > - {children} + + {children} + @@ -32,23 +34,22 @@ function ScrollBar({ className, orientation = "vertical", ...props -}: React.ComponentProps) { +}: React.ComponentProps) { return ( - - - + ); } diff --git a/packages/vitnode/src/components/ui/select.tsx b/packages/vitnode/src/components/ui/select.tsx index cd9bcc8ba..0304a2f70 100644 --- a/packages/vitnode/src/components/ui/select.tsx +++ b/packages/vitnode/src/components/ui/select.tsx @@ -1,7 +1,7 @@ "use client"; +import { Select as SelectPrimitive } from "@base-ui/react/select"; import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"; -import { Select as SelectPrimitive } from "radix-ui"; import * as React from "react"; import { cn } from "@/lib/utils"; @@ -50,9 +50,11 @@ function SelectTrigger({ {...props} > {children} - - - + + } + /> ); } @@ -60,37 +62,47 @@ function SelectTrigger({ function SelectContent({ className, children, - position = "item-aligned", - align = "center", + side = "bottom", + sideOffset = 4, + align = "start", + alignOffset = 0, + alignItemWithTrigger = false, + anchor, ...props -}: React.ComponentProps) { +}: Pick< + React.ComponentProps, + | "align" + | "alignItemWithTrigger" + | "alignOffset" + | "anchor" + | "side" + | "sideOffset" +> & + React.ComponentProps) { return ( - - {children} - + - + ); } @@ -98,9 +110,9 @@ function SelectContent({ function SelectLabel({ className, ...props -}: React.ComponentProps) { +}: React.ComponentProps) { return ( - ) { +}: React.ComponentProps) { return ( - - + ); } function SelectScrollDownButton({ className, ...props -}: React.ComponentProps) { +}: React.ComponentProps) { return ( - - + ); } diff --git a/packages/vitnode/src/components/ui/separator.tsx b/packages/vitnode/src/components/ui/separator.tsx index bf810bc4b..f012033de 100644 --- a/packages/vitnode/src/components/ui/separator.tsx +++ b/packages/vitnode/src/components/ui/separator.tsx @@ -1,6 +1,6 @@ "use client"; -import { Separator as SeparatorPrimitive } from "radix-ui"; +import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"; import * as React from "react"; import { cn } from "@/lib/utils"; @@ -8,17 +8,15 @@ import { cn } from "@/lib/utils"; function Separator({ className, orientation = "horizontal", - decorative = true, ...props -}: React.ComponentProps) { +}: React.ComponentProps) { return ( - diff --git a/packages/vitnode/src/components/ui/sheet.tsx b/packages/vitnode/src/components/ui/sheet.tsx index 8a5dd7241..f86a50693 100644 --- a/packages/vitnode/src/components/ui/sheet.tsx +++ b/packages/vitnode/src/components/ui/sheet.tsx @@ -1,43 +1,34 @@ "use client"; +import { Dialog as SheetPrimitive } from "@base-ui/react/dialog"; import { XIcon } from "lucide-react"; import { useTranslations } from "next-intl"; -import { Dialog as SheetPrimitive } from "radix-ui"; import * as React from "react"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; -function Sheet({ ...props }: React.ComponentProps) { +function Sheet({ ...props }: SheetPrimitive.Root.Props) { return ; } -function SheetTrigger({ - ...props -}: React.ComponentProps) { +function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) { return ; } -function SheetClose({ - ...props -}: React.ComponentProps) { +function SheetClose({ ...props }: SheetPrimitive.Close.Props) { return ; } -function SheetPortal({ - ...props -}: React.ComponentProps) { +function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) { return ; } -function SheetOverlay({ - className, - ...props -}: React.ComponentProps) { +function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) { return ( - & { +}: SheetPrimitive.Popup.Props & { showCloseButton?: boolean; side?: "bottom" | "left" | "right" | "top"; }) { @@ -61,9 +52,9 @@ function SheetContent({ return ( - {children} {showCloseButton && ( - - + + } + > + )} - + ); } @@ -108,10 +98,7 @@ function SheetFooter({ className, ...props }: React.ComponentProps<"div">) { ); } -function SheetTitle({ - className, - ...props -}: React.ComponentProps) { +function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) { return ( ) { +}: SheetPrimitive.Description.Props) { return ( ) { function SidebarGroupLabel({ className, - asChild = false, + render, ...props -}: React.ComponentProps<"div"> & { asChild?: boolean }) { - const Comp = asChild ? Slot.Root : "div"; - - return ( - svg]:size-4 [&>svg]:shrink-0", - className, - )} - data-sidebar="group-label" - data-slot="sidebar-group-label" - {...props} - /> - ); +}: React.ComponentProps<"div"> & useRender.ComponentProps<"div">) { + return useRender({ + defaultTagName: "div", + props: mergeProps<"div">( + { + className: cn( + "text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0", + className, + ), + }, + props, + ), + render, + state: { + slot: "sidebar-group-label", + sidebar: "group-label", + }, + }); } function SidebarGroupAction({ className, - asChild = false, + render, ...props -}: React.ComponentProps<"button"> & { asChild?: boolean }) { - const Comp = asChild ? Slot.Root : "button"; - - return ( - svg]:size-4 [&>svg]:shrink-0", - className, - )} - data-sidebar="group-action" - data-slot="sidebar-group-action" - {...props} - /> - ); +}: React.ComponentProps<"button"> & useRender.ComponentProps<"button">) { + return useRender({ + defaultTagName: "button", + props: mergeProps<"button">( + { + className: cn( + "text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute end-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0", + className, + ), + }, + props, + ), + render, + state: { + slot: "sidebar-group-action", + sidebar: "group-action", + }, + }); } function SidebarGroupContent({ @@ -494,7 +503,7 @@ const sidebarMenuButtonVariants = cva( ); function SidebarMenuButton({ - asChild = false, + render, isActive = false, variant = "default", size = "default", @@ -502,24 +511,29 @@ function SidebarMenuButton({ className, ...props }: React.ComponentProps<"button"> & + useRender.ComponentProps<"button"> & VariantProps & { - asChild?: boolean; isActive?: boolean; tooltip?: React.ComponentProps | string; }) { - const Comp = asChild ? Slot.Root : "button"; const { isMobile, state } = useSidebar(); - const button = ( - - ); + const button = useRender({ + defaultTagName: "button", + props: mergeProps<"button">( + { + className: cn(sidebarMenuButtonVariants({ variant, size }), className), + }, + props, + ), + render: !tooltip ? render : , + state: { + slot: "sidebar-menu-button", + sidebar: "menu-button", + size, + active: isActive, + }, + }); if (!tooltip) { return button; @@ -534,7 +548,7 @@ function SidebarMenuButton({ return ( - + {button}

- - + + } + > + diff --git a/packages/vitnode/src/views/admin/views/core/users/actions.tsx b/packages/vitnode/src/views/admin/views/core/users/actions.tsx index 99c84d44e..e1aa63839 100644 --- a/packages/vitnode/src/views/admin/views/core/users/actions.tsx +++ b/packages/vitnode/src/views/admin/views/core/users/actions.tsx @@ -1,6 +1,6 @@ "use client"; -import { EyeIcon } from "lucide-react"; +import { PenIcon } from "lucide-react"; import { useTranslations } from "next-intl"; import { buttonVariants } from "@/components/ui/button"; @@ -26,12 +26,12 @@ export const UsersAdminActions = ({ nameCode={nameCode} /> - + - + diff --git a/packages/vitnode/src/views/admin/views/core/users/actions/create/create.tsx b/packages/vitnode/src/views/admin/views/core/users/actions/create/create.tsx index 041f777f1..6a45c7767 100644 --- a/packages/vitnode/src/views/admin/views/core/users/actions/create/create.tsx +++ b/packages/vitnode/src/views/admin/views/core/users/actions/create/create.tsx @@ -27,11 +27,9 @@ export const CreateUserAdmin = () => { return ( - - + }> + + {t("title")} diff --git a/packages/vitnode/src/views/admin/views/core/users/show/edit-buttons.tsx b/packages/vitnode/src/views/admin/views/core/users/show/edit-buttons.tsx new file mode 100644 index 000000000..6a1870e3e --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/users/show/edit-buttons.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { ImageIcon, Trash2Icon, UploadIcon } from "lucide-react"; +import { useTranslations } from "next-intl"; + +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; + +export const EditImageButton = ({ label }: { label: string }) => { + const t = useTranslations("admin.user.show"); + + return ( + + } + > + + + + + + {t("uploadImage")} + + + + {t("removeImage")} + + + + ); +}; diff --git a/packages/vitnode/src/views/admin/views/core/users/show/edit-field.tsx b/packages/vitnode/src/views/admin/views/core/users/show/edit-field.tsx new file mode 100644 index 000000000..dd743e20b --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/users/show/edit-field.tsx @@ -0,0 +1,156 @@ +"use client"; + +import { CheckIcon, MailIcon, PencilIcon, XIcon } from "lucide-react"; +import { useTranslations } from "next-intl"; +import * as React from "react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { TooltipWithContent } from "@/components/ui/tooltip"; +import { useRouter } from "@/lib/navigation"; + +import { mutationApi } from "./mutation-api"; + +export const EditUserField = ({ + nameCode, + field, + value, + label, + type = "text", + as: Tag = "span", + valueClassName, + showUnverified = false, +}: { + as?: "h2" | "span"; + field: "email" | "name"; + label: string; + nameCode: string; + showUnverified?: boolean; + type?: "email" | "text"; + value: string; + valueClassName?: string; +}) => { + const t = useTranslations("admin.user"); + const tGlobal = useTranslations("core.global"); + const tError = useTranslations("core.global.errors"); + const router = useRouter(); + const [isEditing, setIsEditing] = React.useState(false); + const [draft, setDraft] = React.useState(value); + const [isPending, startTransition] = React.useTransition(); + + const openEditor = () => { + setDraft(value); + setIsEditing(true); + }; + + const onSave = () => { + const next = draft.trim(); + + if (!next || next === value) { + setIsEditing(false); + + return; + } + + startTransition(async () => { + const mutation = await mutationApi(nameCode, { [field]: next }); + + if ("error" in mutation) { + toast.error(tError("title"), { + description: + mutation.error.status === 409 + ? t( + field === "email" + ? "create.email.exists" + : "create.name.exists", + ) + : tError("internal_server_error"), + }); + + return; + } + + toast.success(t("show.updateSuccess")); + setIsEditing(false); + + // Renaming regenerates the nameCode (the URL identifier), so follow it. + if (mutation.data.nameCode !== nameCode) { + router.replace(`/admin/core/users/${mutation.data.nameCode}`); + } + }); + }; + + if (isEditing) { + return ( +
{ + e.preventDefault(); + onSave(); + }} + > + { + setDraft(e.target.value); + }} + onKeyDown={e => { + if (e.key === "Escape") { + setIsEditing(false); + } + }} + required + type={type} + value={draft} + /> + + +
+ ); + } + + return ( +
+
+ {value} + {showUnverified && ( + + + + )} +
+ +
+ ); +}; diff --git a/packages/vitnode/src/views/admin/views/core/users/show/edit-name-code.tsx b/packages/vitnode/src/views/admin/views/core/users/show/edit-name-code.tsx new file mode 100644 index 000000000..9bc3e4456 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/users/show/edit-name-code.tsx @@ -0,0 +1,151 @@ +"use client"; + +import { LinkIcon, PencilIcon, TriangleAlertIcon } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { toast } from "sonner"; +import { z } from "zod"; + +import type { AutoFormOnSubmit } from "@/components/form/auto-form"; + +import { AutoForm } from "@/components/form/auto-form"; +import { AutoFormInput } from "@/components/form/fields/input"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, + useDialog, +} from "@/components/ui/dialog"; +import { useRouter } from "@/lib/navigation"; + +import { mutationApi } from "./mutation-api"; + +export const EditNameCode = ({ nameCode }: { nameCode: string }) => { + const t = useTranslations("admin.user.show"); + + return ( + + + } + > + + + + + + + + {t("editNameCode")} + + {t("editNameCodeDesc")} + + + + + {t("editNameCodeWarningTitle")} + {t("editNameCodeWarning")} + + + + + + ); +}; + +// Rendered as a child of so `useDialog()` can close it on success. +const FormEditNameCode = ({ nameCode }: { nameCode: string }) => { + const t = useTranslations("admin.user.show"); + const tError = useTranslations("core.global.errors"); + const { setOpen, setIsDirty } = useDialog(); + const router = useRouter(); + + const formSchema = z.object({ + newNameCode: z + .string({ message: tError("field_required") }) + .min(3, tError("field_min_length", { min: 3 })) + .max(255) + .regex(/^[a-zA-Z0-9-]+$/, t("nameCodeInvalid")) + .refine(value => value !== nameCode, t("nameCodeSame")) + .default(""), + currentNameCode: z + .string({ message: tError("field_required") }) + .refine(value => value === nameCode, t("nameCodeConfirmMismatch")) + .default(""), + }); + + const onSubmit: AutoFormOnSubmit = async ( + values, + form, + ) => { + const mutation = await mutationApi(nameCode, { + nameCode: values.newNameCode, + }); + + if ("data" in mutation) { + setIsDirty?.(false); + setOpen?.(false); + toast.success(t("updateSuccess")); + // The name code is the URL identifier, so follow it to the new address. + router.replace(`/admin/core/users/${mutation.data.nameCode}`); + + return; + } + + if (mutation.error.status === 409) { + form.setError( + "newNameCode", + { type: "manual", message: t("nameCodeExists") }, + { shouldFocus: true }, + ); + + return; + } + + toast.error(tError("title"), { + description: tError("internal_server_error"), + }); + }; + + return ( + ( + {chunks}, + nameCode: () => {nameCode}, + })} + {...props} + /> + ), + }, + { + id: "newNameCode", + component: props => ( + + ), + }, + ]} + formSchema={formSchema} + mode="all" + onSubmit={onSubmit} + submitButtonProps={{ + children: t("saveNameCode"), + variant: "destructive", + }} + /> + ); +}; diff --git a/packages/vitnode/src/views/admin/views/core/users/show/mutation-api.ts b/packages/vitnode/src/views/admin/views/core/users/show/mutation-api.ts new file mode 100644 index 000000000..6294c6673 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/users/show/mutation-api.ts @@ -0,0 +1,34 @@ +"use server"; + +import { revalidatePath } from "next/cache"; + +import { adminModule } from "@/api/modules/admin/admin.module"; +import { fetcher } from "@/lib/fetcher"; + +type MutationResult = + | { data: { email: string; id: number; name: string; nameCode: string } } + | { error: { status: number } }; + +export const mutationApi = async ( + nameCode: string, + input: { email?: string; name?: string; nameCode?: string }, +): Promise => { + const res = await fetcher(adminModule, { + path: "/{nameCode}", + method: "patch", + module: "admin/users", + args: { + params: { nameCode }, + body: input, + }, + }); + + if (res.status !== 200) { + return { error: { status: res.status } }; + } + + const data = await res.json(); + revalidatePath("/[locale]/admin", "layout"); + + return { data }; +}; diff --git a/packages/vitnode/src/views/admin/views/core/users/show/show-user-admin-view.tsx b/packages/vitnode/src/views/admin/views/core/users/show/show-user-admin-view.tsx index 6041a4a1d..fd0327edd 100644 --- a/packages/vitnode/src/views/admin/views/core/users/show/show-user-admin-view.tsx +++ b/packages/vitnode/src/views/admin/views/core/users/show/show-user-admin-view.tsx @@ -1,15 +1,18 @@ -import { CalendarIcon, ImageIcon, MailIcon } from "lucide-react"; +import { ExternalLinkIcon } from "lucide-react"; import { getTranslations } from "next-intl/server"; import { notFound } from "next/navigation"; import { adminModule } from "@/api/modules/admin/admin.module"; import { Avatar } from "@/components/avatar"; import { DateFormat } from "@/components/date-format"; +import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; -import { TooltipWithContent } from "@/components/ui/tooltip"; import { fetcher } from "@/lib/fetcher"; +import { Link } from "@/lib/navigation"; -import { VerifyEmailUserAdmin } from "../actions/verify-email/verify-email"; +import { EditImageButton } from "./edit-buttons"; +import { EditUserField } from "./edit-field"; +import { EditNameCode } from "./edit-name-code"; export const ShowUserAdminView = async ({ nameCode }: { nameCode: string }) => { const t = await getTranslations("admin.user.show"); @@ -29,55 +32,74 @@ export const ShowUserAdminView = async ({ nameCode }: { nameCode: string }) => { const user = await res.json(); return ( - + {/* Cover placeholder */} -
- +
{t("coverPlaceholder")} +
+ +
- -
- {/* Avatar placeholder */} - - -
-
-

- {user.name} -

- {!user.emailVerified && ( - - - - )} + + {/* Avatar */} +
+
+ +
+
- - @{user.nameCode} -
+
+ + {/* Username */} + - + + @{user.nameCode} + + +
+ + {/* Email */} +
+
-
-
- - {user.email} -
-
- - - {t("joined")} - -
+

+ {t("joined")} +

+ + {/* Actions */} +
+
diff --git a/packages/vitnode/src/views/admin/views/core/users/users-admin-view.tsx b/packages/vitnode/src/views/admin/views/core/users/users-admin-view.tsx index a67c3f8ee..9ec410b6f 100644 --- a/packages/vitnode/src/views/admin/views/core/users/users-admin-view.tsx +++ b/packages/vitnode/src/views/admin/views/core/users/users-admin-view.tsx @@ -4,6 +4,7 @@ import { getTranslations } from "next-intl/server"; import { adminModule } from "@/api/modules/admin/admin.module"; import { Avatar } from "@/components/avatar"; import { DateFormat } from "@/components/date-format"; +import { RoleFormat } from "@/components/role-format"; import { DataTable } from "@/components/table/data-table"; import { TooltipWithContent } from "@/components/ui/tooltip"; import { fetcher } from "@/lib/fetcher"; @@ -40,21 +41,40 @@ export const UsersAdminView = async ({
-
- {row.name} - {!row.emailVerified && ( - - - - )} -
+ {row.name} - {row.email} + @{row.nameCode}
), }, + { + id: "email", + label: t("email"), + cell: ({ row }) => { + if (row.emailVerified) { + return {row.email}; + } + + return ( +
+ + + + + + {row.email} + +
+ ); + }, + }, + { + id: "roleId", + label: t("roles"), + cell: ({ row }) => , + }, { id: "createdAt", label: t("createdAt"), diff --git a/packages/vitnode/src/views/auth/settings/layout.tsx b/packages/vitnode/src/views/auth/settings/layout.tsx new file mode 100644 index 000000000..8aaf153a4 --- /dev/null +++ b/packages/vitnode/src/views/auth/settings/layout.tsx @@ -0,0 +1,24 @@ +import { notFound } from "next/navigation"; + +import { I18nProvider } from "@/components/i18n-provider"; +import { getSessionApi } from "@/lib/api/get-session-api"; + +import { SettingsShell } from "./shell"; + +export const LayoutSettings = async ({ + children, +}: { + children: React.ReactNode; +}) => { + const session = await getSessionApi(); + + if (!session.user) { + notFound(); + } + + return ( + + {children} + + ); +}; diff --git a/packages/vitnode/src/views/auth/settings/nav.tsx b/packages/vitnode/src/views/auth/settings/nav.tsx new file mode 100644 index 000000000..038ab4257 --- /dev/null +++ b/packages/vitnode/src/views/auth/settings/nav.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { ChevronRightIcon, KeyRoundIcon, UserRoundIcon } from "lucide-react"; +import { useTranslations } from "next-intl"; + +import { buttonVariants } from "@/components/ui/button"; +import { Link, usePathname } from "@/lib/navigation"; +import { cn, normalizeUrl } from "@/lib/utils"; + +const items = [ + { + href: "/settings/overview", + key: "overview", + icon: UserRoundIcon, + aliases: ["/settings"], + }, + { href: "/settings/security", key: "security", icon: KeyRoundIcon }, +] as const; + +export const NavSettings = () => { + const t = useTranslations("core.auth.settings.nav"); + const pathname = normalizeUrl(usePathname()); + + return ( + + ); +}; diff --git a/packages/vitnode/src/views/auth/settings/overview/overview.tsx b/packages/vitnode/src/views/auth/settings/overview/overview.tsx new file mode 100644 index 000000000..629981645 --- /dev/null +++ b/packages/vitnode/src/views/auth/settings/overview/overview.tsx @@ -0,0 +1,9 @@ +import { getTranslations } from "next-intl/server"; + +import { HeaderContent } from "@/components/ui/header-content"; + +export const OverviewSettings = async () => { + const t = await getTranslations("core.auth.settings.nav"); + + return ; +}; diff --git a/packages/vitnode/src/views/auth/settings/security/security.tsx b/packages/vitnode/src/views/auth/settings/security/security.tsx new file mode 100644 index 000000000..02616452c --- /dev/null +++ b/packages/vitnode/src/views/auth/settings/security/security.tsx @@ -0,0 +1,9 @@ +import { getTranslations } from "next-intl/server"; + +import { HeaderContent } from "@/components/ui/header-content"; + +export const SecuritySettings = async () => { + const t = await getTranslations("core.auth.settings.nav"); + + return ; +}; diff --git a/packages/vitnode/src/views/auth/settings/shell.tsx b/packages/vitnode/src/views/auth/settings/shell.tsx new file mode 100644 index 000000000..bb8163546 --- /dev/null +++ b/packages/vitnode/src/views/auth/settings/shell.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { ArrowLeftIcon } from "lucide-react"; +import { useTranslations } from "next-intl"; + +import { buttonVariants } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { HeaderContent } from "@/components/ui/header-content"; +import { Link, usePathname } from "@/lib/navigation"; +import { cn, normalizeUrl } from "@/lib/utils"; + +import { NavSettings } from "./nav"; + +export const SettingsShell = ({ children }: { children: React.ReactNode }) => { + const t = useTranslations("core.auth.settings"); + // On the index path the nav acts as the mobile landing menu; any deeper path + // is a "detail" page that takes over the full view on mobile. + const isRoot = normalizeUrl(usePathname()) === "/settings"; + + return ( +
+ + +
+ + + + + + + + + + + {t("title")} + + + {children} + + +
+
+ ); +}; diff --git a/packages/vitnode/src/views/auth/sso/callback/client/client.tsx b/packages/vitnode/src/views/auth/sso/callback/client/client.tsx index 0db9e8db4..37ca72a9e 100644 --- a/packages/vitnode/src/views/auth/sso/callback/client/client.tsx +++ b/packages/vitnode/src/views/auth/sso/callback/client/client.tsx @@ -45,8 +45,12 @@ export const ClientCallbackSSO = ({ - {t("email_exists.sign_in")} + } customDescription={t.rich("email_exists.desc", { diff --git a/packages/vitnode/src/views/breadcrumb/breadcrumb-main.tsx b/packages/vitnode/src/views/breadcrumb/breadcrumb-main.tsx index 0c7a8f504..59dff191b 100644 --- a/packages/vitnode/src/views/breadcrumb/breadcrumb-main.tsx +++ b/packages/vitnode/src/views/breadcrumb/breadcrumb-main.tsx @@ -25,7 +25,7 @@ export const BreadcrumbMain = ({ } return ( -
+
); diff --git a/packages/vitnode/src/views/breadcrumb/breadcrumb-render.tsx b/packages/vitnode/src/views/breadcrumb/breadcrumb-render.tsx index c31205fc8..f46d6f566 100644 --- a/packages/vitnode/src/views/breadcrumb/breadcrumb-render.tsx +++ b/packages/vitnode/src/views/breadcrumb/breadcrumb-render.tsx @@ -29,9 +29,9 @@ export const BreadcrumbRender = ({ crumbs }: { crumbs: BreadcrumbCrumb[] }) => { {crumb.isCurrent ? ( {crumb.label} ) : crumb.isLink ? ( - - {crumb.label} - + {crumb.label}} + /> ) : ( {crumb.label} )} diff --git a/packages/vitnode/src/views/layouts/root-layout.tsx b/packages/vitnode/src/views/layouts/root-layout.tsx index cf7f46039..889db28be 100644 --- a/packages/vitnode/src/views/layouts/root-layout.tsx +++ b/packages/vitnode/src/views/layouts/root-layout.tsx @@ -25,7 +25,6 @@ export const generateMetadataRootLayout = ({ default: title, template: `%s - ${shortTitle ?? title}`, }, - description: "Generated by create next app", }; }; diff --git a/packages/vitnode/src/views/layouts/theme/header/user/auth/auth.tsx b/packages/vitnode/src/views/layouts/theme/header/user/auth/auth.tsx index cde27420d..8e5225693 100644 --- a/packages/vitnode/src/views/layouts/theme/header/user/auth/auth.tsx +++ b/packages/vitnode/src/views/layouts/theme/header/user/auth/auth.tsx @@ -15,10 +15,10 @@ export const AuthUserHeader = async () => { return ( - - + } + > + diff --git a/packages/vitnode/src/views/layouts/theme/header/user/auth/client.tsx b/packages/vitnode/src/views/layouts/theme/header/user/auth/client.tsx index 7e5359d13..843d030a5 100644 --- a/packages/vitnode/src/views/layouts/theme/header/user/auth/client.tsx +++ b/packages/vitnode/src/views/layouts/theme/header/user/auth/client.tsx @@ -1,6 +1,12 @@ "use client"; -import { KeyRoundIcon, LogOutIcon } from "lucide-react"; +import { + KeyRoundIcon, + LogOutIcon, + Settings, + ShieldIcon, + UserIcon, +} from "lucide-react"; import { useTranslations } from "next-intl"; import type { SessionApi } from "@/lib/api/get-session-api"; @@ -23,15 +29,35 @@ export const ClientAuthUserHeader = ({ return ( <> - {user.isAdmin && ( + + }> + + {t("my_profile")} + + + }> + + {t("settings")} + + + + + + {(user.isAdmin || user.isModerator) && ( <> - - + {user.isModerator && ( + }> + + {t("mod_cp")} + + )} + {user.isAdmin && ( + }> {t("admin_cp")} - - + + )} diff --git a/plugins/blog/src/views/admin/categories/actions/actions.tsx b/plugins/blog/src/views/admin/categories/actions/actions.tsx index 9d5c85c22..fdfea47ed 100644 --- a/plugins/blog/src/views/admin/categories/actions/actions.tsx +++ b/plugins/blog/src/views/admin/categories/actions/actions.tsx @@ -26,11 +26,9 @@ export const ActionsCategoriesAdmin = () => { return ( - - + }> + + {t("title")} diff --git a/plugins/blog/src/views/admin/categories/table/actions/edit-action.tsx b/plugins/blog/src/views/admin/categories/table/actions/edit-action.tsx index d6841f743..fc29e26ea 100644 --- a/plugins/blog/src/views/admin/categories/table/actions/edit-action.tsx +++ b/plugins/blog/src/views/admin/categories/table/actions/edit-action.tsx @@ -38,10 +38,12 @@ export const EditAction = ( - + + } + > + } /> diff --git a/plugins/blog/src/views/admin/posts/actions/actions.tsx b/plugins/blog/src/views/admin/posts/actions/actions.tsx index e47b2b996..9630734e4 100644 --- a/plugins/blog/src/views/admin/posts/actions/actions.tsx +++ b/plugins/blog/src/views/admin/posts/actions/actions.tsx @@ -26,11 +26,9 @@ export const ActionsPostsAdmin = () => { return ( - - + }> + + {t("title")} diff --git a/plugins/blog/src/views/admin/posts/table/actions/edit-action.tsx b/plugins/blog/src/views/admin/posts/table/actions/edit-action.tsx index 9923e950f..15498bde7 100644 --- a/plugins/blog/src/views/admin/posts/table/actions/edit-action.tsx +++ b/plugins/blog/src/views/admin/posts/table/actions/edit-action.tsx @@ -38,10 +38,12 @@ export const EditAction = ( - + + } + > + } /> diff --git a/scripts/bump-version/files/file-copy-manager.ts b/scripts/bump-version/files/file-copy-manager.ts index b2da4ffe1..a7ff2f443 100644 --- a/scripts/bump-version/files/file-copy-manager.ts +++ b/scripts/bump-version/files/file-copy-manager.ts @@ -37,7 +37,6 @@ export class FileCopyManager { "src/app/[locale]/admin", "src/app/favicon.ico", "src/app/global-error.tsx", - "src/app/layout.tsx", "src/app/not-found.tsx", "postcss.config.mjs", ]);