Update control panel layout + add new action bar (#1756)

This commit is contained in:
Luke Vella 2025-06-05 11:18:28 +01:00 committed by GitHub
parent 87b8c76492
commit 1146586e14
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 307 additions and 185 deletions

View file

@ -394,5 +394,6 @@
"disableUserRegistration": "Disable User Registration",
"disableUserRegistrationDescription": "Prevent new users from registering an account.",
"authenticationAndSecurity": "Authentication & Security",
"authenticationAndSecurityDescription": "Manage authentication and security settings"
"authenticationAndSecurityDescription": "Manage authentication and security settings",
"youHaveUnsavedChanges": "You have unsaved changes"
}

View file

@ -1,4 +1,3 @@
import { ActionBar } from "@rallly/ui/action-bar";
import { Button } from "@rallly/ui/button";
import { SidebarInset, SidebarTrigger } from "@rallly/ui/sidebar";
import Link from "next/link";
@ -51,7 +50,6 @@ export default async function Layout({
<div className="flex flex-1 flex-col">
<div className="flex flex-1 flex-col">{children}</div>
</div>
<ActionBar />
</SidebarInset>
</AppSidebarProvider>
</TimezoneProvider>

View file

@ -17,7 +17,9 @@ export default async function AdminLayout({
<ControlPanelSidebar />
<SidebarInset>
<LicenseLimitWarning />
<div className="flex min-w-0 flex-1 flex-col">{children}</div>
<div className="flex min-w-0 flex-1 flex-col">
<div className="flex-1">{children}</div>
</div>
</SidebarInset>
</ControlPanelSidebarProvider>
);

View file

@ -1,10 +1,4 @@
import { PageIcon } from "@/app/components/page-icons";
import {
PageContainer,
PageContent,
PageHeader,
PageTitle,
} from "@/app/components/page-layout";
import { requireAdmin } from "@/auth/queries";
import {
EmptyState,
@ -13,6 +7,12 @@ import {
EmptyStateIcon,
EmptyStateTitle,
} from "@/components/empty-state";
import {
FullWidthLayout,
FullWidthLayoutContent,
FullWidthLayoutHeader,
FullWidthLayoutTitle,
} from "@/components/full-width-layout";
import { Trans } from "@/components/trans";
import { LicenseKeyForm } from "@/features/licensing/components/license-key-form";
import { RemoveLicenseButton } from "@/features/licensing/components/remove-license-button";
@ -59,16 +59,19 @@ function DescriptionListValue({
export default async function LicensePage() {
const { license } = await loadData();
return (
<PageContainer>
<PageHeader>
<PageTitle>
<PageIcon color="darkGray">
<FullWidthLayout>
<FullWidthLayoutHeader>
<FullWidthLayoutTitle
icon={
<PageIcon size="sm" color="darkGray">
<KeySquareIcon />
</PageIcon>
}
>
<Trans i18nKey="license" defaults="License" />
</PageTitle>
</PageHeader>
<PageContent>
</FullWidthLayoutTitle>
</FullWidthLayoutHeader>
<FullWidthLayoutContent>
{license ? (
<div>
<DescriptionList>
@ -174,8 +177,8 @@ export default async function LicensePage() {
</EmptyStateFooter>
</EmptyState>
)}
</PageContent>
</PageContainer>
</FullWidthLayoutContent>
</FullWidthLayout>
);
}

View file

@ -1,11 +1,11 @@
import { PageIcon } from "@/app/components/page-icons";
import {
PageContainer,
PageContent,
PageHeader,
PageTitle,
} from "@/app/components/page-layout";
import { requireAdmin } from "@/auth/queries";
import {
FullWidthLayout,
FullWidthLayoutContent,
FullWidthLayoutHeader,
FullWidthLayoutTitle,
} from "@/components/full-width-layout";
import { Trans } from "@/components/trans";
import { getLicense } from "@/features/licensing/queries";
import { prisma } from "@rallly/database";
@ -37,16 +37,19 @@ async function loadData() {
export default async function AdminPage() {
const { userCount, userLimit, tier } = await loadData();
return (
<PageContainer>
<PageHeader>
<PageTitle>
<PageIcon color="indigo">
<FullWidthLayout>
<FullWidthLayoutHeader>
<FullWidthLayoutTitle
icon={
<PageIcon size="sm" color="indigo">
<GaugeIcon />
</PageIcon>
}
>
<Trans i18nKey="controlPanel" defaults="Control Panel" />
</PageTitle>
</PageHeader>
<PageContent className="space-y-8">
</FullWidthLayoutTitle>
</FullWidthLayoutHeader>
<FullWidthLayoutContent>
<div className="space-y-4">
<h2 className="text-muted-foreground text-sm">
<Trans i18nKey="homeNavTitle" defaults="Navigation" />
@ -122,8 +125,8 @@ export default async function AdminPage() {
</Tile>
</TileGrid>
</div>
</PageContent>
</PageContainer>
</FullWidthLayoutContent>
</FullWidthLayout>
);
}

View file

@ -1,13 +1,12 @@
"use server";
import { requireAdmin } from "@/auth/queries";
import type { InstanceSettings } from "@/features/instance-settings/schema";
import { prisma } from "@rallly/database";
export async function setDisableUserRegistration({
export async function updateInstanceSettings({
disableUserRegistration,
}: {
disableUserRegistration: boolean;
}) {
}: InstanceSettings) {
await requireAdmin();
await prisma.instanceSettings.update({

View file

@ -1,36 +0,0 @@
"use client";
import { Trans } from "@/components/trans";
import { Label } from "@rallly/ui/label";
import { Switch } from "@rallly/ui/switch";
import { setDisableUserRegistration } from "./actions";
export function DisableUserRegistration({
defaultValue,
}: { defaultValue: boolean }) {
return (
<div>
<div className="flex items-center gap-2">
<Switch
id="disable-user-registration"
onCheckedChange={(checked) => {
setDisableUserRegistration({ disableUserRegistration: checked });
}}
defaultChecked={defaultValue}
/>
<Label htmlFor="disable-user-registration">
<Trans
i18nKey="disableUserRegistration"
defaults="Disable User Registration"
/>
</Label>
</div>
<p className="mt-2 text-muted-foreground text-sm">
<Trans
i18nKey="disableUserRegistrationDescription"
defaults="Prevent new users from registering an account."
/>
</p>
</div>
);
}

View file

@ -0,0 +1,145 @@
"use client";
import {
SettingsGroup,
SettingsGroupContent,
SettingsGroupDescription,
SettingsGroupHeader,
SettingsGroupTitle,
} from "@/components/settings-group";
import { Trans } from "@/components/trans";
import {
type InstanceSettings,
instanceSettingsSchema,
} from "@/features/instance-settings/schema";
import { useTranslation } from "@/i18n/client";
import { zodResolver } from "@hookform/resolvers/zod";
import {
ActionBar,
ActionBarGroup,
ActionBarTitle,
} from "@rallly/ui/action-bar";
import { Button } from "@rallly/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
} from "@rallly/ui/form";
import { useToast } from "@rallly/ui/hooks/use-toast";
import { Switch } from "@rallly/ui/switch";
import { useForm } from "react-hook-form";
import { updateInstanceSettings } from "./actions";
export function InstanceSettingsForm({
defaultValue,
}: {
defaultValue: InstanceSettings;
}) {
const form = useForm<InstanceSettings>({
defaultValues: defaultValue,
resolver: zodResolver(instanceSettingsSchema),
});
const { t } = useTranslation();
const { toast } = useToast();
return (
<Form {...form}>
<form
name="instance-settings-form"
onSubmit={form.handleSubmit(async (data) => {
try {
await updateInstanceSettings(data);
form.reset(data);
} catch (error) {
console.error(error);
toast({
title: t("unexpectedError", {
defaultValue: "Unexpected Error",
}),
description: t("unexpectedErrorDescription", {
defaultValue:
"There was an unexpected error. Please try again later.",
}),
});
}
})}
>
<SettingsGroup>
<SettingsGroupHeader>
<SettingsGroupTitle>
<Trans
i18nKey="authenticationAndSecurity"
defaults="Authentication & Security"
/>
</SettingsGroupTitle>
<SettingsGroupDescription>
<Trans
i18nKey="authenticationAndSecurityDescription"
defaults="Manage authentication and security settings"
/>
</SettingsGroupDescription>
</SettingsGroupHeader>
<SettingsGroupContent>
<FormField
control={form.control}
name="disableUserRegistration"
render={({ field }) => (
<FormItem>
<div className="flex items-center gap-2">
<FormControl>
<Switch
onCheckedChange={field.onChange}
checked={field.value}
/>
</FormControl>
<FormLabel>
<Trans
i18nKey="disableUserRegistration"
defaults="Disable User Registration"
/>
</FormLabel>
</div>
<FormDescription>
<Trans
i18nKey="disableUserRegistrationDescription"
defaults="Prevent new users from registering an account."
/>
</FormDescription>
</FormItem>
)}
/>
</SettingsGroupContent>
</SettingsGroup>
<ActionBar open={form.formState.isDirty}>
<ActionBarTitle>
<Trans
i18nKey="youHaveUnsavedChanges"
defaults="You have unsaved changes"
/>
</ActionBarTitle>
<ActionBarGroup>
<Button
variant="actionBar"
type="button"
onClick={() => form.reset()}
>
<Trans i18nKey="cancel" defaults="Cancel" />
</Button>
<Button
loading={form.formState.isSubmitting}
variant="primary"
type="submit"
>
<Trans i18nKey="save" defaults="Save" />
</Button>
</ActionBarGroup>
</ActionBar>
</form>
</Form>
);
}

View file

@ -8,7 +8,7 @@ import {
import { Trans } from "@/components/trans";
import { getInstanceSettings } from "@/features/instance-settings/queries";
import { SettingsIcon } from "lucide-react";
import { DisableUserRegistration } from "./disable-user-registration";
import { InstanceSettingsForm } from "./instance-settings-form";
async function loadData() {
const instanceSettings = await getInstanceSettings();
@ -35,27 +35,7 @@ export default async function SettingsPage() {
</FullWidthLayoutTitle>
</FullWidthLayoutHeader>
<FullWidthLayoutContent>
<div className="flex flex-col gap-6 rounded-lg border p-6 lg:flex-row">
<div className="lg:w-1/2">
<h2 className="font-semibold text-base">
<Trans
i18nKey="authenticationAndSecurity"
defaults="Authentication & Security"
/>
</h2>
<p className="mt-1 text-muted-foreground text-sm">
<Trans
i18nKey="authenticationAndSecurityDescription"
defaults="Manage authentication and security settings"
/>
</p>
</div>
<div className="flex-1">
<DisableUserRegistration
defaultValue={instanceSettings?.disableUserRegistration}
/>
</div>
</div>
<InstanceSettingsForm defaultValue={instanceSettings} />
</FullWidthLayoutContent>
</FullWidthLayout>
);

View file

@ -1,10 +1,4 @@
import { PageIcon } from "@/app/components/page-icons";
import {
PageContainer,
PageContent,
PageHeader,
PageTitle,
} from "@/app/components/page-layout";
import { requireAdmin } from "@/auth/queries";
import {
EmptyState,
@ -12,6 +6,12 @@ import {
EmptyStateIcon,
EmptyStateTitle,
} from "@/components/empty-state";
import {
FullWidthLayout,
FullWidthLayoutContent,
FullWidthLayoutHeader,
FullWidthLayoutTitle,
} from "@/components/full-width-layout";
import { Pagination } from "@/components/pagination";
import { StackedList } from "@/components/stacked-list";
import { Trans } from "@/components/trans";
@ -113,18 +113,19 @@ export default async function AdminPage(props: {
const totalItems = allUsers.length;
return (
<PageContainer>
<PageHeader>
<div className="flex items-center gap-4">
<PageTitle>
<PageIcon color="darkGray">
<FullWidthLayout>
<FullWidthLayoutHeader>
<FullWidthLayoutTitle
icon={
<PageIcon size="sm" color="darkGray">
<UsersIcon />
</PageIcon>
}
>
<Trans i18nKey="users" defaults="Users" />
</PageTitle>
</div>
</PageHeader>
<PageContent>
</FullWidthLayoutTitle>
</FullWidthLayoutHeader>
<FullWidthLayoutContent>
<div className="space-y-4">
<UserSearchInput />
<UsersTabbedView>
@ -167,8 +168,8 @@ export default async function AdminPage(props: {
)}
</UsersTabbedView>
</div>
</PageContent>
</PageContainer>
</FullWidthLayoutContent>
</FullWidthLayout>
);
}

View file

@ -1,3 +1,5 @@
import { SidebarTrigger } from "@rallly/ui/sidebar";
export function FullWidthLayout({ children }: { children: React.ReactNode }) {
return <div>{children}</div>;
}
@ -6,8 +8,13 @@ export function FullWidthLayoutHeader({
children,
}: { children: React.ReactNode }) {
return (
<header className="sticky top-0 z-10 rounded-t-lg border-b bg-background/90 px-6 py-4 backdrop-blur-sm">
{children}
<header className="sticky top-0 z-10 rounded-t-lg border-b bg-background/90 px-3 py-4 backdrop-blur-sm md:px-6">
<div className="flex items-center gap-4">
<div className="md:hidden">
<SidebarTrigger />
</div>
<div className="flex-1">{children}</div>
</div>
</header>
);
}
@ -15,7 +22,7 @@ export function FullWidthLayoutHeader({
export function FullWidthLayoutContent({
children,
}: { children: React.ReactNode }) {
return <main className="p-6">{children}</main>;
return <main className="p-3 pb-44 md:px-6 md:pt-6">{children}</main>;
}
export function FullWidthLayoutTitle({
@ -23,9 +30,9 @@ export function FullWidthLayoutTitle({
icon,
}: { children: React.ReactNode; icon?: React.ReactNode }) {
return (
<div className="flex items-center gap-2">
<div className="flex items-center gap-2.5">
{icon}
<h1 className="font-semibold text-xl">{children}</h1>
<h1 className="font-bold text-xl">{children}</h1>
</div>
);
}

View file

@ -0,0 +1,31 @@
export function SettingsGroup({ children }: { children: React.ReactNode }) {
return (
<div className="flex flex-col gap-6 rounded-lg border p-6 lg:flex-row">
{children}
</div>
);
}
export function SettingsGroupHeader({
children,
}: { children: React.ReactNode }) {
return <div className="lg:w-1/3">{children}</div>;
}
export function SettingsGroupTitle({
children,
}: { children: React.ReactNode }) {
return <h2 className="font-semibold text-base">{children}</h2>;
}
export function SettingsGroupDescription({
children,
}: { children: React.ReactNode }) {
return <p className="mt-1 text-muted-foreground text-sm">{children}</p>;
}
export function SettingsGroupContent({
children,
}: { children: React.ReactNode }) {
return <div className="flex-1">{children}</div>;
}

View file

@ -0,0 +1,7 @@
import { z } from "zod";
export const instanceSettingsSchema = z.object({
disableUserRegistration: z.boolean(),
});
export type InstanceSettings = z.infer<typeof instanceSettingsSchema>;

View file

@ -51,7 +51,7 @@ module.exports = {
},
"action-bar": {
DEFAULT: colors.gray["800"],
foreground: colors.white,
foreground: colors.gray["50"],
},
muted: {
DEFAULT: colors.gray["100"],

View file

@ -1,67 +1,54 @@
import * as Portal from "@radix-ui/react-portal";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import * as React from "react";
import { cn } from "./lib/utils";
const ACTION_BAR_PORTAL_ID = "action-bar-portal";
interface ActionBarProps
extends Omit<
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>,
"open" | "onOpenChange"
> {
open?: boolean;
onOpenChange?: (open: boolean) => void;
children: React.ReactNode;
}
const ActionBar = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"pointer-events-none sticky bottom-8 flex justify-center pb-5",
className,
)}
id={ACTION_BAR_PORTAL_ID}
{...props}
/>
));
ActionBar.displayName = "ActionBar";
const ActionBarPortal = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<Portal.Root
container={document.getElementById(ACTION_BAR_PORTAL_ID)}
ref={ref}
className={className}
{...props}
/>
));
ActionBarPortal.displayName = "ActionBarPortal";
const ActionBarContainer = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
React.ComponentRef<typeof DialogPrimitive.Content>,
ActionBarProps
>(({ open, onOpenChange, children, className, ...props }, ref) => {
return (
<div
<DialogPrimitive.Root modal={false} open={open} onOpenChange={onOpenChange}>
<DialogPrimitive.Content
forceMount={true}
ref={ref}
className={cn(
"pointer-events-auto z-50 mx-auto inline-flex w-full max-w-2xl items-center gap-4 rounded-xl bg-action-bar p-2 text-action-bar-foreground shadow-lg",
"-translate-x-1/2 fixed bottom-3 z-50 flex items-start gap-2 rounded-xl bg-action-bar p-2 text-action-bar-foreground shadow-lg transition-transform duration-200 ease-out data-[state=closed]:pointer-events-none data-[state=closed]:translate-y-full data-[state=open]:translate-y-0 data-[state=closed]:opacity-0 data-[state=open]:opacity-100",
"left-1/2 md:bottom-16 md:w-fit",
"w-[calc(100%-24px)]",
className,
)}
{...props}
/>
>
{children}
</DialogPrimitive.Content>
</DialogPrimitive.Root>
);
});
ActionBarContainer.displayName = "ActionBarContainer";
ActionBar.displayName = "ActionBar";
const ActionBarContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
const ActionBarTitle = React.forwardRef<
HTMLHeadingElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, children, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn("flex items-center px-2.5", className)}
className={cn("flex flex-1 items-center px-2.5 py-2 text-sm", className)}
{...props}
/>
>
{children}
</DialogPrimitive.Title>
));
ActionBarContent.displayName = "ActionBarContent";
ActionBarTitle.displayName = "ActionBarTitle";
const ActionBarGroup = React.forwardRef<
HTMLDivElement,
@ -69,16 +56,10 @@ const ActionBarGroup = React.forwardRef<
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center gap-2", className)}
className={cn("flex justify-end gap-2", className)}
{...props}
/>
));
ActionBarGroup.displayName = "ActionBarGroup";
export {
ActionBar,
ActionBarContainer,
ActionBarContent,
ActionBarGroup,
ActionBarPortal,
};
export { ActionBar, ActionBarTitle, ActionBarGroup };

View file

@ -25,7 +25,7 @@ const buttonVariants = cva(
ghost:
"border-transparent bg-transparent text-gray-800 hover:bg-gray-500/10 active:bg-gray-500/20 data-[state=open]:bg-gray-500/20",
actionBar:
"border-transparent bg-transparent text-gray-800 hover:bg-gray-700 active:bg-gray-700/50 data-[state=open]:bg-gray-500/20",
"border-transparent bg-action-bar text-action-bar-foreground hover:bg-action-bar-foreground/10 data-[state=open]:bg-action-bar-foreground/20",
link: "border-transparent text-primary underline-offset-4 hover:underline",
},
size: {