This commit is contained in:
 Илья Шалонин 2025-10-29 13:43:43 +05:00
commit 8538ab969a
434 changed files with 44051 additions and 0 deletions

View file

@ -0,0 +1,33 @@
import { config } from "@repo/config";
import { NavBar } from "@saas/shared/components/NavBar";
import { cn } from "@ui/lib";
import type { PropsWithChildren } from "react";
export function AppWrapper({ children }: PropsWithChildren) {
return (
<div
className={cn(
"bg-[radial-gradient(farthest-corner_at_0%_0%,color-mix(in_oklch,var(--color-primary),transparent_95%)_0%,var(--color-background)_50%)] dark:bg-[radial-gradient(farthest-corner_at_0%_0%,color-mix(in_oklch,var(--color-primary),transparent_90%)_0%,var(--color-background)_50%)]",
[config.ui.saas.useSidebarLayout ? "" : ""],
)}
>
<NavBar />
<div
className={cn(" md:pr-4 py-4 flex", [
config.ui.saas.useSidebarLayout
? "min-h-[calc(100vh)] md:ml-[280px]"
: "",
])}
>
<main
className={cn(
"py-6 border rounded-2xl bg-card px-4 md:p-8 min-h-full w-full",
[config.ui.saas.useSidebarLayout ? "" : ""],
)}
>
<div className="container px-0">{children}</div>
</main>
</div>
</div>
);
}

View file

@ -0,0 +1,49 @@
import { config } from "@repo/config";
import { Footer } from "@saas/shared/components/Footer";
import { ColorModeToggle } from "@shared/components/ColorModeToggle";
import { LocaleSwitch } from "@shared/components/LocaleSwitch";
import { Logo } from "@shared/components/Logo";
import { cn } from "@ui/lib";
import Link from "next/link";
import { type PropsWithChildren, Suspense } from "react";
export function AuthWrapper({
children,
contentClass,
}: PropsWithChildren<{ contentClass?: string }>) {
return (
<div className="flex min-h-screen w-full py-6">
<div className="flex w-full flex-col items-center justify-between gap-8">
<div className="container">
<div className="flex items-center justify-between">
<Link href="/" className="block">
<Logo />
</Link>
<div className="flex items-center justify-end gap-2">
{config.i18n.enabled && (
<Suspense>
<LocaleSwitch withLocaleInUrl={false} />
</Suspense>
)}
<ColorModeToggle />
</div>
</div>
</div>
<div className="container flex justify-center">
<main
className={cn(
"w-full max-w-md rounded-3xl bg-card p-6 border lg:p-8",
contentClass,
)}
>
{children}
</main>
</div>
<Footer />
</div>
</div>
);
}

View file

@ -0,0 +1,101 @@
"use client";
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@ui/components/alert-dialog";
import { Button } from "@ui/components/button";
import { useTranslations } from "next-intl";
import {
type PropsWithChildren,
createContext,
useContext,
useState,
} from "react";
type ConfirmOptions = {
title: string;
message?: string;
cancelLabel?: string;
confirmLabel?: string;
destructive?: boolean;
onConfirm: () => Promise<void> | void;
};
const ConfirmationAlertContext = createContext<{
confirm: (options: ConfirmOptions) => void;
}>({
confirm: async () => false,
});
export function ConfirmationAlertProvider({ children }: PropsWithChildren) {
const t = useTranslations();
const [confirmOptions, setConfirmOptions] = useState<ConfirmOptions | null>(
null,
);
const confirm = (options: ConfirmOptions) => {
setConfirmOptions(options);
};
return (
<ConfirmationAlertContext.Provider value={{ confirm }}>
{children}
<AlertDialog
open={!!confirmOptions}
onOpenChange={(open) =>
setConfirmOptions(open ? confirmOptions : null)
}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{confirmOptions?.title}
</AlertDialogTitle>
</AlertDialogHeader>
<AlertDialogDescription>
{confirmOptions?.message}
</AlertDialogDescription>
<AlertDialogFooter>
<AlertDialogCancel>
{confirmOptions?.cancelLabel ??
t("common.confirmation.cancel")}
</AlertDialogCancel>
<Button
variant={
confirmOptions?.destructive
? "error"
: "primary"
}
onClick={async () => {
await confirmOptions?.onConfirm();
setConfirmOptions(null);
}}
>
{confirmOptions?.confirmLabel ??
t("common.confirmation.confirm")}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</ConfirmationAlertContext.Provider>
);
}
export const useConfirmationAlert = () => {
const context = useContext(ConfirmationAlertContext);
if (!context) {
throw new Error(
"useConfirmationAlert must be used within a ConfirmationAlertProvider",
);
}
return context;
};

View file

@ -0,0 +1,20 @@
import { LocaleLink } from "@i18n/routing";
import { cn } from "@ui/lib";
export function Footer() {
return (
<footer
className={cn(
"container max-w-6xl py-6 text-center text-foreground/60 text-xs",
)}
>
<span>
<a href="https://supastarter.dev">Built with supastarter</a>
</span>
<span className="opacity-50"> | </span>
<LocaleLink href="/legal/privacy-policy">Privacy policy</LocaleLink>
<span className="opacity-50"> | </span>
<LocaleLink href="/legal/terms">Terms and conditions</LocaleLink>
</footer>
);
}

View file

@ -0,0 +1,189 @@
"use client";
import { config } from "@repo/config";
import { useSession } from "@saas/auth/hooks/use-session";
import { useActiveOrganization } from "@saas/organizations/hooks/use-active-organization";
import { UserMenu } from "@saas/shared/components/UserMenu";
import { Logo } from "@shared/components/Logo";
import { cn } from "@ui/lib";
import {
BotMessageSquareIcon,
ChevronRightIcon,
HomeIcon,
SettingsIcon,
UserCog2Icon,
UserCogIcon,
} from "lucide-react";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { OrganzationSelect } from "../../organizations/components/OrganizationSelect";
export function NavBar() {
const t = useTranslations();
const pathname = usePathname();
const { user } = useSession();
const { activeOrganization } = useActiveOrganization();
const { useSidebarLayout } = config.ui.saas;
const basePath = activeOrganization
? `/app/${activeOrganization.slug}`
: "/app";
const menuItems = [
{
label: t("app.menu.start"),
href: basePath,
icon: HomeIcon,
isActive: pathname === basePath,
},
{
label: t("app.menu.aiChatbot"),
href: activeOrganization
? `/app/${activeOrganization.slug}/chatbot`
: "/app/chatbot",
icon: BotMessageSquareIcon,
isActive: pathname.includes("/chatbot"),
},
...(activeOrganization
? [
{
label: t("app.menu.organizationSettings"),
href: `${basePath}/settings`,
icon: SettingsIcon,
isActive: pathname.startsWith(`${basePath}/settings/`),
},
]
: []),
{
label: t("app.menu.accountSettings"),
href: "/app/settings",
icon: UserCog2Icon,
isActive: pathname.startsWith("/app/settings/"),
},
...(user?.role === "admin"
? [
{
label: t("app.menu.admin"),
href: "/app/admin",
icon: UserCogIcon,
isActive: pathname.startsWith("/app/admin/"),
},
]
: []),
];
return (
<nav
className={cn("w-full", {
"w-full md:fixed md:top-0 md:left-0 md:h-full md:w-[280px]":
useSidebarLayout,
})}
>
<div
className={cn("container max-w-6xl py-4", {
"container max-w-6xl py-4 md:flex md:h-full md:flex-col md:px-6 md:pt-6 md:pb-0":
useSidebarLayout,
})}
>
<div className="flex flex-wrap items-center justify-between gap-4">
<div
className={cn("flex items-center gap-4 md:gap-2", {
"md:flex md:w-full md:flex-col md:items-stretch md:align-stretch":
useSidebarLayout,
})}
>
<Link href="/app" className="block">
<Logo />
</Link>
{config.organizations.enable &&
!config.organizations.hideOrganization && (
<>
<span
className={cn(
"hidden opacity-30 md:block",
{
"md:hidden": useSidebarLayout,
},
)}
>
<ChevronRightIcon className="size-4" />
</span>
<OrganzationSelect
className={cn({
"md:-mx-2 md:mt-2":
useSidebarLayout,
})}
/>
</>
)}
</div>
<div
className={cn(
"mr-0 ml-auto flex items-center justify-end gap-4",
{
"md:hidden": useSidebarLayout,
},
)}
>
<UserMenu />
</div>
</div>
<ul
className={cn(
"no-scrollbar -mx-4 -mb-4 mt-6 flex list-none items-center justify-start gap-4 overflow-x-auto px-4 text-sm",
{
"md:mx-0 md:my-4 md:flex md:flex-col md:items-stretch md:gap-1 md:px-0":
useSidebarLayout,
},
)}
>
{menuItems.map((menuItem) => (
<li key={menuItem.href}>
<Link
href={menuItem.href}
className={cn(
"flex items-center gap-2 whitespace-nowrap border-b-2 px-1 pb-3",
[
menuItem.isActive
? "border-primary font-bold"
: "border-transparent",
],
{
"md:-mx-6 md:border-b-0 md:border-l-2 md:px-6 md:py-2":
useSidebarLayout,
},
)}
prefetch
>
<menuItem.icon
className={`size-4 shrink-0 ${
menuItem.isActive
? "text-primary"
: "opacity-50"
}`}
/>
<span>{menuItem.label}</span>
</Link>
</li>
))}
</ul>
<div
className={cn(
"-mx-4 md:-mx-6 mt-auto mb-0 hidden p-4 md:p-4",
{
"md:block": useSidebarLayout,
},
)}
>
<UserMenu showUserName />
</div>
</div>
</nav>
);
}

View file

@ -0,0 +1,16 @@
"use client";
export function PageHeader({
title,
subtitle,
}: {
title: string;
subtitle?: string;
}) {
return (
<div className="mb-8">
<h2 className="font-bold text-2xl lg:text-3xl">{title}</h2>
<p className="mt-1 opacity-60">{subtitle}</p>
</div>
);
}

View file

@ -0,0 +1,53 @@
import { Button } from "@ui/components/button";
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
export type PaginatioProps = {
className?: string;
totalItems: number;
itemsPerPage: number;
currentPage: number;
onChangeCurrentPage: (page: number) => void;
};
const Pagination = ({
currentPage,
totalItems,
itemsPerPage,
className,
onChangeCurrentPage,
}: PaginatioProps) => {
const numberOfPages = Math.ceil(totalItems / itemsPerPage);
return (
<div className={className}>
<div className="flex items-center justify-center gap-4">
<Button
variant="ghost"
size="icon"
disabled={currentPage === 1}
onClick={() => onChangeCurrentPage(currentPage - 1)}
>
<ChevronLeftIcon />
</Button>
<span className="text-gray-500 text-sm">
{currentPage * itemsPerPage - itemsPerPage + 1} -{" "}
{currentPage * itemsPerPage > totalItems
? totalItems
: currentPage * itemsPerPage}{" "}
of {totalItems}
</span>
<Button
variant="ghost"
size="icon"
disabled={currentPage === numberOfPages}
onClick={() => onChangeCurrentPage(currentPage + 1)}
>
<ChevronRightIcon />
</Button>
</div>
</div>
);
};
Pagination.displayName = "Pagination";
export { Pagination };

View file

@ -0,0 +1,37 @@
import { Card } from "@ui/components/card";
import { cn } from "@ui/lib";
import type { PropsWithChildren, ReactNode } from "react";
export function SettingsItem({
children,
title,
description,
danger,
}: PropsWithChildren<{
title: string | ReactNode;
description?: string | ReactNode;
danger?: boolean;
}>) {
return (
<Card className="@container p-4 rounded-2xl border md:p-6">
<div className="grid @-xl:grid-cols-[min(100%/3,280px)_auto] grid-cols-1 @xl:gap-8 gap-4">
<div className="flex shrink-0 flex-col gap-1.5">
<h3
className={cn(
"m-0 font-semibold leading-tight",
danger && "text-destructive",
)}
>
{title}
</h3>
{description && (
<p className="m-0 text-foreground/60 text-xs">
{description}
</p>
)}
</div>
{children}
</div>
</Card>
);
}

View file

@ -0,0 +1,13 @@
import type { PropsWithChildren } from "react";
export function SettingsList({ children }: PropsWithChildren) {
return (
<div className="@container flex flex-col gap-4">
{Array.isArray(children)
? children.filter(Boolean).map((child, i) => {
return <div key={`settings-item-${i}`}>{child}</div>;
})
: children}
</div>
);
}

View file

@ -0,0 +1,18 @@
import type { ReactNode } from "react";
export function SidebarContentLayout({
children,
sidebar,
}: { children: React.ReactNode; sidebar: ReactNode }) {
return (
<div className="relative">
<div className="flex flex-col items-start gap-4 lg:flex-row lg:gap-8">
<div className="top-4 w-full lg:sticky lg:max-w-[180px]">
{sidebar}
</div>
<div className="w-full flex-1">{children}</div>
</div>
</div>
);
}

View file

@ -0,0 +1,36 @@
"use client";
import Link from "next/link";
import { useSelectedLayoutSegment } from "next/navigation";
import { useMemo } from "react";
export function TabGroup({
items,
className,
}: {
items: { label: string; href: string; segment: string }[];
className?: string;
}) {
const selectedSegment = useSelectedLayoutSegment();
const activeItem = useMemo(() => {
return items.find((item) => item.segment === selectedSegment);
}, [items, selectedSegment]);
return (
<div className={` flex border-b-2 ${className}`}>
{items.map((item) => (
<Link
key={item.href}
href={item.href}
className={`-mb-0.5 block border-b-2 px-6 py-3 ${
item === activeItem
? "border-primary font-bold"
: "border-transparent"
}`}
>
{item.label}
</Link>
))}
</div>
);
}

View file

@ -0,0 +1,176 @@
"use client";
import { DropdownMenuSub } from "@radix-ui/react-dropdown-menu";
import { authClient } from "@repo/auth/client";
import { config } from "@repo/config";
import { useSession } from "@saas/auth/hooks/use-session";
import { UserAvatar } from "@shared/components/UserAvatar";
import { clearCache } from "@shared/lib/cache";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@ui/components/dropdown-menu";
import {
BookIcon,
HardDriveIcon,
HomeIcon,
LogOutIcon,
MoonIcon,
MoreVerticalIcon,
SettingsIcon,
SunIcon,
} from "lucide-react";
import { useTranslations } from "next-intl";
import { useTheme } from "next-themes";
import Link from "next/link";
import { useState } from "react";
export function UserMenu({ showUserName }: { showUserName?: boolean }) {
const t = useTranslations();
const { user } = useSession();
const { setTheme: setCurrentTheme, theme: currentTheme } = useTheme();
const [theme, setTheme] = useState<string>(currentTheme ?? "system");
const colorModeOptions = [
{
value: "system",
label: "System",
icon: HardDriveIcon,
},
{
value: "light",
label: "Light",
icon: SunIcon,
},
{
value: "dark",
label: "Dark",
icon: MoonIcon,
},
];
const onLogout = () => {
authClient.signOut({
fetchOptions: {
onSuccess: async () => {
await clearCache();
window.location.href = new URL(
config.auth.redirectAfterLogout,
window.location.origin,
).toString();
},
},
});
};
if (!user) {
return null;
}
const { name, email, image } = user;
return (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex cursor-pointer w-full items-center justify-between gap-2 rounded-lg outline-hidden focus-visible:ring-2 focus-visible:ring-primary md:w-[100%+1rem] md:px-2 md:py-1.5 md:hover:bg-primary/5"
aria-label="User menu"
>
<span className="flex items-center gap-2">
<UserAvatar name={name ?? ""} avatarUrl={image} />
{showUserName && (
<span className="text-left leading-tight">
<span className="font-medium text-sm">
{name}
</span>
<span className="block text-xs opacity-70">
{email}
</span>
</span>
)}
</span>
{showUserName && <MoreVerticalIcon className="size-4" />}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>
{name}
<span className="block font-normal text-xs opacity-70">
{email}
</span>
</DropdownMenuLabel>
<DropdownMenuSeparator />
{/* Color mode selection */}
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<SunIcon className="mr-2 size-4" />
{t("app.userMenu.colorMode")}
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent>
<DropdownMenuRadioGroup
value={theme}
onValueChange={(value) => {
setTheme(value);
setCurrentTheme(value);
}}
>
{colorModeOptions.map((option) => (
<DropdownMenuRadioItem
key={option.value}
value={option.value}
>
<option.icon className="mr-2 size-4 opacity-50" />
{option.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href="/app/settings/general">
<SettingsIcon className="mr-2 size-4" />
{t("app.userMenu.accountSettings")}
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<a href="https://supastarter.dev/docs/nextjs">
<BookIcon className="mr-2 size-4" />
{t("app.userMenu.documentation")}
</a>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href="/">
<HomeIcon className="mr-2 size-4" />
{t("app.userMenu.home")}
</Link>
</DropdownMenuItem>
<DropdownMenuItem onClick={onLogout}>
<LogOutIcon className="mr-2 size-4" />
{t("app.userMenu.logout")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}