feat: add new theme (#157)
This commit is contained in:
@@ -1,11 +1,16 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
You are a Staff Engineer and an Expert in ReactJS, NextJS, JavaScript, TypeScript, HTML, CSS, NodeJS, Prisma, Postgres, and modern UI/UX frameworks (e.g., TailwindCSS, Shadcn, Radix). You are also great at scalling things. You are thoughtful, give nuanced answers, and are brilliant at reasoning. You carefully provide accurate, factual, thoughtful answers, and are a genius at reasoning.
|
||||
|
||||
- Follow the user’s requirements carefully & to the letter.
|
||||
- Follow the user's requirements carefully & to the letter.
|
||||
- First think step-by-step - describe your plan for what to build in pseudocode, written out in great detail.
|
||||
- Always write correct, best practice, bug free, fully functional and working code also it should be aligned to listed rules down below at Code Implementation Guidelines .
|
||||
- Focus on easy and readability code, over being performant.
|
||||
- Fully implement all requested functionality.
|
||||
- Leave NO todo’s, placeholders or missing pieces.
|
||||
- Leave NO todo's, placeholders or missing pieces.
|
||||
- Ensure code is complete! Verify thoroughly finalised.
|
||||
- Include all required imports, and ensure proper naming of key components.
|
||||
- Be concise Minimize any other prose.
|
@@ -1,6 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
||||
|
@@ -28,7 +28,7 @@ export default function CampaignDetailsPage({
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen">
|
||||
<Spinner className="w-5 h-5 text-primary" />
|
||||
<Spinner className="w-5 h-5 text-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -94,7 +94,7 @@ export default function CampaignDetailsPage({
|
||||
<div className="capitalize">{card.status.toLowerCase()}</div>
|
||||
</div>
|
||||
<div className="flex justify-between items-end">
|
||||
<div className="text-primary font-light text-2xl font-mono">
|
||||
<div className="text-foreground font-light text-2xl font-mono">
|
||||
{card.count}
|
||||
</div>
|
||||
{card.status !== "total" ? (
|
||||
|
@@ -89,7 +89,7 @@ export default function CampaignList() {
|
||||
<TableRow key={campaign.id} className="">
|
||||
<TableCell className="font-medium">
|
||||
<Link
|
||||
className="underline underline-offset-4 decoration-dashed text-foreground hover:text-primary"
|
||||
className="underline underline-offset-4 decoration-dashed text-foreground hover:text-foreground"
|
||||
href={
|
||||
campaign.status === CampaignStatus.DRAFT
|
||||
? `/campaigns/${campaign.id}/edit`
|
||||
@@ -154,170 +154,3 @@ export default function CampaignList() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// "use client";
|
||||
|
||||
// import {
|
||||
// Table,
|
||||
// TableHeader,
|
||||
// TableRow,
|
||||
// TableHead,
|
||||
// TableBody,
|
||||
// TableCell,
|
||||
// } from "@unsend/ui/src/table";
|
||||
// import { api } from "~/trpc/react";
|
||||
// import { useUrlState } from "~/hooks/useUrlState";
|
||||
// import { Button } from "@unsend/ui/src/button";
|
||||
// import Spinner from "@unsend/ui/src/spinner";
|
||||
// import { formatDistanceToNow } from "date-fns";
|
||||
// import { CampaignStatus } from "@prisma/client";
|
||||
// import DeleteCampaign from "./delete-campaign";
|
||||
// import Link from "next/link";
|
||||
// import DuplicateCampaign from "./duplicate-campaign";
|
||||
// import { motion } from "framer-motion";
|
||||
// import { useRouter } from "next/navigation";
|
||||
// import {
|
||||
// Select,
|
||||
// SelectTrigger,
|
||||
// SelectContent,
|
||||
// SelectItem,
|
||||
// } from "@unsend/ui/src/select";
|
||||
|
||||
// export default function CampaignList() {
|
||||
// const [page, setPage] = useUrlState("page", "1");
|
||||
// const [status, setStatus] = useUrlState("status");
|
||||
|
||||
// const pageNumber = Number(page);
|
||||
|
||||
// const campaignsQuery = api.campaign.getCampaigns.useQuery({
|
||||
// page: pageNumber,
|
||||
// status: status as CampaignStatus | null,
|
||||
// });
|
||||
|
||||
// const router = useRouter();
|
||||
|
||||
// return (
|
||||
// <div className="mt-10 flex flex-col gap-4">
|
||||
// <div className="flex justify-end">
|
||||
// <Select
|
||||
// value={status ?? "all"}
|
||||
// onValueChange={(val) => setStatus(val === "all" ? null : val)}
|
||||
// >
|
||||
// <SelectTrigger className="w-[180px] capitalize">
|
||||
// {status ? status.toLowerCase() : "All statuses"}
|
||||
// </SelectTrigger>
|
||||
// <SelectContent>
|
||||
// <SelectItem value="all" className=" capitalize">
|
||||
// All statuses
|
||||
// </SelectItem>
|
||||
// <SelectItem value={CampaignStatus.DRAFT} className=" capitalize">
|
||||
// Draft
|
||||
// </SelectItem>
|
||||
// <SelectItem
|
||||
// value={CampaignStatus.SCHEDULED}
|
||||
// className=" capitalize"
|
||||
// >
|
||||
// Scheduled
|
||||
// </SelectItem>
|
||||
// <SelectItem value={CampaignStatus.SENT} className=" capitalize">
|
||||
// Sent
|
||||
// </SelectItem>
|
||||
// </SelectContent>
|
||||
// </Select>
|
||||
// </div>
|
||||
|
||||
// {campaignsQuery.isLoading ? (
|
||||
// <div className="flex justify-center items-center mt-20">
|
||||
// <Spinner
|
||||
// className="w-5 h-5 text-primary"
|
||||
// innerSvgClass="stroke-primary"
|
||||
// />
|
||||
// </div>
|
||||
// ) : (
|
||||
// <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8 ">
|
||||
// {campaignsQuery.data?.campaigns.map((campaign) => (
|
||||
// <motion.div
|
||||
// whileHover={{ scale: 1.01 }}
|
||||
// transition={{ type: "spring", stiffness: 600, damping: 10 }}
|
||||
// whileTap={{ scale: 0.99 }}
|
||||
// className="border rounded-xl shadow hover:shadow-lg"
|
||||
// key={campaign.id}
|
||||
// >
|
||||
// <div className="flex flex-col">
|
||||
// <Link
|
||||
// href={
|
||||
// campaign.status === CampaignStatus.DRAFT
|
||||
// ? `/campaigns/${campaign.id}/edit`
|
||||
// : `/campaigns/${campaign.id}`
|
||||
// }
|
||||
// >
|
||||
// <div className="h-40 overflow-hidden flex justify-center rounded-t-xl bg-muted/10">
|
||||
// <div
|
||||
// className="transform scale-[0.5] "
|
||||
// dangerouslySetInnerHTML={{ __html: campaign.html ?? "" }}
|
||||
// />
|
||||
// </div>
|
||||
// </Link>
|
||||
|
||||
// <div className="flex justify-between items-center shadow-[0px_-5px_25px_-8px_rgba(0,0,0,0.3)] rounded-xl -mt-2 z-10 bg-background">
|
||||
// <div
|
||||
// className="cursor-pointer w-full py-3 pl-4 flex gap-2 items-start"
|
||||
// onClick={() => router.push(`/campaigns/${campaign.id}`)}
|
||||
// >
|
||||
// <div className="flex flex-col gap-2">
|
||||
// <div className="flex gap-4">
|
||||
// <div className="font-semibold text-sm">
|
||||
// {campaign.name}
|
||||
// </div>
|
||||
// <div
|
||||
// className={`text-center px-4 rounded capitalize py-0.5 text-xs ${
|
||||
// campaign.status === CampaignStatus.DRAFT
|
||||
// ? "bg-gray-500/15 dark:bg-gray-600/10 text-gray-700 dark:text-gray-600/90 border border-gray-500/25 dark:border-gray-700/25"
|
||||
// : campaign.status === CampaignStatus.SENT
|
||||
// ? "bg-green-500/15 dark:bg-green-600/10 text-green-700 dark:text-green-600/90 border border-green-500/25 dark:border-green-700/25"
|
||||
// : "bg-yellow-500/15 dark:bg-yellow-600/10 text-yellow-700 dark:text-yellow-600/90 border border-yellow-500/25 dark:border-yellow-700/25"
|
||||
// }`}
|
||||
// >
|
||||
// {campaign.status.toLowerCase()}
|
||||
// </div>
|
||||
// </div>
|
||||
// <div className="text-muted-foreground text-xs">
|
||||
// {formatDistanceToNow(campaign.createdAt, {
|
||||
// addSuffix: true,
|
||||
// })}
|
||||
// </div>
|
||||
// </div>
|
||||
// </div>
|
||||
|
||||
// <div className="flex gap-2 pr-4">
|
||||
// <DuplicateCampaign campaign={campaign} />
|
||||
// <DeleteCampaign campaign={campaign} />
|
||||
// </div>
|
||||
// </div>
|
||||
// </div>
|
||||
// </motion.div>
|
||||
// ))}
|
||||
// </div>
|
||||
// )}
|
||||
|
||||
// {campaignsQuery.data?.totalPage && campaignsQuery.data.totalPage > 1 ? (
|
||||
// <div className="flex gap-4 justify-end">
|
||||
// <Button
|
||||
// size="sm"
|
||||
// onClick={() => setPage((pageNumber - 1).toString())}
|
||||
// disabled={pageNumber === 1}
|
||||
// >
|
||||
// Previous
|
||||
// </Button>
|
||||
// <Button
|
||||
// size="sm"
|
||||
// onClick={() => setPage((pageNumber + 1).toString())}
|
||||
// disabled={pageNumber >= (campaignsQuery.data?.totalPage ?? 0)}
|
||||
// >
|
||||
// Next
|
||||
// </Button>
|
||||
// </div>
|
||||
// ) : null}
|
||||
// </div>
|
||||
// );
|
||||
// }
|
||||
|
@@ -83,8 +83,10 @@ export const DeleteCampaign: React.FC<{
|
||||
<DialogTitle>Delete Campaign</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete{" "}
|
||||
<span className="font-semibold text-primary">{campaign.name}</span>?
|
||||
You can't reverse this.
|
||||
<span className="font-semibold text-foreground">
|
||||
{campaign.name}
|
||||
</span>
|
||||
? You can't reverse this.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-2">
|
||||
|
@@ -54,7 +54,10 @@ export const DuplicateCampaign: React.FC<{
|
||||
<DialogTitle>Duplicate Campaign</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to duplicate{" "}
|
||||
<span className="font-semibold text-primary">{campaign.name}</span>?
|
||||
<span className="font-semibold text-foreground">
|
||||
{campaign.name}
|
||||
</span>
|
||||
?
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-2">
|
||||
|
@@ -87,8 +87,10 @@ export const DeleteContact: React.FC<{
|
||||
<DialogTitle>Delete Contact</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete{" "}
|
||||
<span className="font-semibold text-primary">{contact.email}</span>?
|
||||
You can't reverse this.
|
||||
<span className="font-semibold text-foreground">
|
||||
{contact.email}
|
||||
</span>
|
||||
? You can't reverse this.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-2">
|
||||
|
@@ -145,7 +145,7 @@ export const EditContact: React.FC<{
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
className="data-[state=checked]:bg-emerald-500"
|
||||
className="data-[state=checked]:bg-success"
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
|
@@ -86,7 +86,7 @@ export const DeleteContactBook: React.FC<{
|
||||
<DialogTitle>Delete Contact Book</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete{" "}
|
||||
<span className="font-semibold text-primary">
|
||||
<span className="font-semibold text-foreground">
|
||||
{contactBook.name}
|
||||
</span>
|
||||
? You can't reverse this.
|
||||
|
@@ -78,7 +78,7 @@ export const EditContactBook: React.FC<{
|
||||
className="p-0 hover:bg-transparent"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Edit className="h-4 w-4 text-primary/80 hover:text-primary/70" />
|
||||
<Edit className="h-4 w-4 text-foreground/80 hover:text-foreground/70" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
|
@@ -1,211 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { useSession } from "next-auth/react";
|
||||
import Link from "next/link";
|
||||
import { LogoutButton, NavButton } from "./nav-button";
|
||||
import {
|
||||
BookOpenText,
|
||||
BookUser,
|
||||
CircleUser,
|
||||
Code,
|
||||
Cog,
|
||||
Globe,
|
||||
Home,
|
||||
LayoutDashboard,
|
||||
LayoutTemplate,
|
||||
LineChart,
|
||||
Mail,
|
||||
Menu,
|
||||
Package,
|
||||
Package2,
|
||||
Server,
|
||||
Settings,
|
||||
ShoppingCart,
|
||||
Users,
|
||||
Volume2,
|
||||
} from "lucide-react";
|
||||
import { env } from "~/env";
|
||||
import { Sheet, SheetContent, SheetTrigger } from "@unsend/ui/src/sheet";
|
||||
import { Button } from "@unsend/ui/src/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@unsend/ui/src/dropdown-menu";
|
||||
import { ThemeSwitcher } from "~/components/theme/ThemeSwitcher";
|
||||
import { isCloud, isSelfHosted } from "~/utils/common";
|
||||
import { AppSidebar } from "~/components/AppSideBar";
|
||||
import { SidebarInset, SidebarTrigger } from "@unsend/ui/src/sidebar";
|
||||
import { SidebarProvider } from "@unsend/ui/src/sidebar";
|
||||
import { useIsMobile } from "@unsend/ui/src/hooks/use-mobile";
|
||||
|
||||
export function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||
const { data: session } = useSession();
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen w-full h-full">
|
||||
<div className="hidden bg-muted/20 md:block md:w-[230px]">
|
||||
<div className="flex h-full max-h-screen flex-col gap-2">
|
||||
<div className="flex h-14 gap-4 items-center px-4 lg:h-[60px] lg:px-6">
|
||||
<Link href="/" className="flex items-center gap-2 font-semibold">
|
||||
<span className=" text-lg">Unsend</span>
|
||||
</Link>
|
||||
<span className="text-[10px] text-muted-foreground bg-muted p-0.5 px-2 rounded-full">
|
||||
Early access
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 h-full">
|
||||
<nav className=" flex-1 h-full flex-col justify-between items-center px-2 text-sm font-medium lg:px-4">
|
||||
<div className="h-[calc(100%-120px)]">
|
||||
<NavButton href="/dashboard">
|
||||
<LayoutDashboard className="h-4 w-4" />
|
||||
Dashboard
|
||||
</NavButton>
|
||||
|
||||
<NavButton href="/emails">
|
||||
<Mail className="h-4 w-4" />
|
||||
Emails
|
||||
</NavButton>
|
||||
|
||||
<NavButton href="/contacts">
|
||||
<BookUser className="h-4 w-4" />
|
||||
Contacts
|
||||
</NavButton>
|
||||
|
||||
<NavButton href="/templates">
|
||||
<LayoutTemplate className="h-4 w-4" />
|
||||
Templates
|
||||
</NavButton>
|
||||
|
||||
<NavButton href="/campaigns">
|
||||
<Volume2 className="h-4 w-4" />
|
||||
Campaigns
|
||||
</NavButton>
|
||||
|
||||
<NavButton href="/domains">
|
||||
<Globe className="h-4 w-4" />
|
||||
Domains
|
||||
</NavButton>
|
||||
|
||||
<NavButton href="/dev-settings">
|
||||
<Code className="h-4 w-4" />
|
||||
Developer settings
|
||||
</NavButton>
|
||||
|
||||
<NavButton href="/settings">
|
||||
<Cog className="h-4 w-4" />
|
||||
Settings
|
||||
</NavButton>
|
||||
|
||||
{isSelfHosted() || session?.user.isAdmin ? (
|
||||
<NavButton href="/admin">
|
||||
<Server className="h-4 w-4" />
|
||||
Admin
|
||||
</NavButton>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="pl-4 flex flex-col gap-2 w-full">
|
||||
<Link
|
||||
href="https://docs.unsend.dev"
|
||||
target="_blank"
|
||||
className="flex gap-2 items-center hover:text-primary text-muted-foreground"
|
||||
>
|
||||
<BookOpenText className="h-4 w-4" />
|
||||
<span className="">Docs</span>
|
||||
</Link>
|
||||
<LogoutButton />
|
||||
<div>
|
||||
<ThemeSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
<div className="mt-auto p-4"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col">
|
||||
<header className=" h-14 items-center gap-4 hidden bg-muted/20 px-4 lg:h-[60px] lg:px-6">
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="shrink-0 md:hidden"
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
<span className="sr-only">Toggle navigation menu</span>
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="left" className="flex flex-col">
|
||||
<nav className="grid gap-2 text-lg font-medium">
|
||||
<Link
|
||||
href="#"
|
||||
className="flex items-center gap-2 text-lg font-semibold"
|
||||
>
|
||||
<Package2 className="h-6 w-6" />
|
||||
<span className="sr-only">Acme Inc</span>
|
||||
</Link>
|
||||
<Link
|
||||
href="#"
|
||||
className="mx-[-0.65rem] flex items-center gap-4 rounded-xl px-3 py-2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Home className="h-5 w-5" />
|
||||
Dashboard
|
||||
</Link>
|
||||
<Link
|
||||
href="#"
|
||||
className="mx-[-0.65rem] flex items-center gap-4 rounded-xl bg-muted px-3 py-2 text-foreground hover:text-foreground"
|
||||
>
|
||||
<ShoppingCart className="h-5 w-5" />
|
||||
Orders
|
||||
</Link>
|
||||
<Link
|
||||
href="#"
|
||||
className="mx-[-0.65rem] flex items-center gap-4 rounded-xl px-3 py-2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Package className="h-5 w-5" />
|
||||
Products
|
||||
</Link>
|
||||
<Link
|
||||
href="#"
|
||||
className="mx-[-0.65rem] flex items-center gap-4 rounded-xl px-3 py-2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Users className="h-5 w-5" />
|
||||
Customers
|
||||
</Link>
|
||||
<Link
|
||||
href="#"
|
||||
className="mx-[-0.65rem] flex items-center gap-4 rounded-xl px-3 py-2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<LineChart className="h-5 w-5" />
|
||||
Analytics
|
||||
</Link>
|
||||
</nav>
|
||||
<div className="mt-auto"></div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="secondary" size="icon" className="rounded-full">
|
||||
<CircleUser className="h-5 w-5" />
|
||||
<span className="sr-only">Toggle user menu</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>My Account</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>Settings</DropdownMenuItem>
|
||||
<DropdownMenuItem>Support</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>Logout</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</header>
|
||||
<main className="flex-1 overflow-y-auto h-full">
|
||||
<div className="flex flex-col gap-4 p-4 w-full lg:max-w-6xl mx-auto lg:gap-6 lg:p-6">
|
||||
<div className="h-full bg-sidebar-background">
|
||||
<SidebarProvider>
|
||||
<AppSidebar />
|
||||
<SidebarInset>
|
||||
<main className="flex-1 overflow-auto h-full p-4 px-40">
|
||||
{isMobile ? (
|
||||
<SidebarTrigger className="h-5 w-5 text-muted-foreground" />
|
||||
) : null}
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</main>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@@ -235,11 +235,13 @@ const DashboardItemCard: React.FC<DashboardItemCardProps> = ({
|
||||
<div className=" capitalize">{status.toLowerCase()}</div>
|
||||
</div>
|
||||
<div className="flex justify-between items-end">
|
||||
<div className="text-primary font-light text-2xl font-mono">
|
||||
<div className="text-foreground font-light text-2xl font-mono">
|
||||
{count}
|
||||
</div>
|
||||
{status !== "total" ? (
|
||||
<div className="text-sm pb-1">{count > 0 ? (percentage * 100).toFixed(0) : 0}%</div>
|
||||
<div className="text-sm pb-1">
|
||||
{count > 0 ? (percentage * 100).toFixed(0) : 0}%
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -83,8 +83,8 @@ export const DeleteApiKey: React.FC<{
|
||||
<DialogTitle>Delete API key</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete{" "}
|
||||
<span className="font-semibold text-primary">{apiKey.name}</span>?
|
||||
You can't reverse this.
|
||||
<span className="font-semibold text-foreground">{apiKey.name}</span>
|
||||
? You can't reverse this.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-2">
|
||||
|
@@ -15,9 +15,9 @@ export const SettingsNavButton: React.FC<{
|
||||
|
||||
if (comingSoon) {
|
||||
return (
|
||||
<div className="flex items-center justify-between hover:text-primary cursor-not-allowed mt-1">
|
||||
<div className="flex items-center justify-between hover:text-foreground cursor-not-allowed mt-1">
|
||||
<div
|
||||
className={`flex items-center gap-3 rounded-lg px-3 py-2 transition-all hover:text-primary cursor-not-allowed ${isActive ? " bg-secondary" : "text-muted-foreground"}`}
|
||||
className={`flex items-center gap-3 rounded-lg px-3 py-2 transition-all hover:text-foreground cursor-not-allowed ${isActive ? " bg-secondary" : "text-muted-foreground"}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
@@ -31,7 +31,7 @@ export const SettingsNavButton: React.FC<{
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className={`flex text-sm items-center mt-1 gap-3 rounded px-2 py-1 transition-all hover:text-primary ${isActive ? " bg-accent" : "text-muted-foreground"}`}
|
||||
className={`flex text-sm items-center mt-1 gap-3 rounded px-2 py-1 transition-all hover:text-foreground ${isActive ? " bg-accent" : "text-muted-foreground"}`}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
|
@@ -5,9 +5,10 @@ import { Input } from "@unsend/ui/src/input";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription, DialogHeader,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger
|
||||
DialogTrigger,
|
||||
} from "@unsend/ui/src/dialog";
|
||||
|
||||
import {
|
||||
@@ -84,8 +85,8 @@ export const DeleteDomain: React.FC<{ domain: Domain }> = ({ domain }) => {
|
||||
<DialogTitle>Delete domain</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete{" "}
|
||||
<span className="font-semibold text-primary">{domain.name}</span>?
|
||||
You can't reverse this.
|
||||
<span className="font-semibold text-foreground">{domain.name}</span>
|
||||
? You can't reverse this.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Form {...domainForm}>
|
||||
|
@@ -274,7 +274,7 @@ const DomainSettings: React.FC<{ domain: Domain }> = ({ domain }) => {
|
||||
<Switch
|
||||
checked={clickTracking}
|
||||
onCheckedChange={handleClickTrackingChange}
|
||||
className="data-[state=checked]:bg-emerald-500"
|
||||
className="data-[state=checked]:bg-success"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -288,7 +288,7 @@ const DomainSettings: React.FC<{ domain: Domain }> = ({ domain }) => {
|
||||
<Switch
|
||||
checked={openTracking}
|
||||
onCheckedChange={handleOpenTrackingChange}
|
||||
className="data-[state=checked]:bg-emerald-500"
|
||||
className="data-[state=checked]:bg-success"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
@@ -104,7 +104,7 @@ const DomainItem: React.FC<{ domain: Domain }> = ({ domain }) => {
|
||||
<Switch
|
||||
checked={clickTracking}
|
||||
onCheckedChange={handleClickTrackingChange}
|
||||
className="data-[state=checked]:bg-emerald-500"
|
||||
className="data-[state=checked]:bg-success"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
@@ -112,7 +112,7 @@ const DomainItem: React.FC<{ domain: Domain }> = ({ domain }) => {
|
||||
<Switch
|
||||
checked={openTracking}
|
||||
onCheckedChange={handleOpenTrackingChange}
|
||||
className="data-[state=checked]:bg-emerald-500"
|
||||
className="data-[state=checked]:bg-success"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -115,7 +115,7 @@ export default function EmailDetails({ emailId }: { emailId: string }) {
|
||||
<div className="text-xs text-muted-foreground mt-2">
|
||||
{formatDate(evt.createdAt, "MMM dd, hh:mm a")}
|
||||
</div>
|
||||
<div className="mt-1 text-primary/80">
|
||||
<div className="mt-1 text-foreground/80">
|
||||
<EmailStatusText
|
||||
status={evt.status}
|
||||
data={evt.data}
|
||||
|
@@ -1,53 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { LogOut } from "lucide-react";
|
||||
import { signOut } from "next-auth/react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import React from "react";
|
||||
|
||||
export const NavButton: React.FC<{
|
||||
href: string;
|
||||
children: React.ReactNode;
|
||||
comingSoon?: boolean;
|
||||
}> = ({ href, children, comingSoon }) => {
|
||||
const pathname = usePathname();
|
||||
|
||||
const isActive = pathname?.startsWith(href);
|
||||
|
||||
if (comingSoon) {
|
||||
return (
|
||||
<div className="flex items-center justify-between hover:text-primary cursor-not-allowed mt-1">
|
||||
<div
|
||||
className={`flex items-center gap-3 rounded-lg px-3 py-2 transition-all hover:text-primary cursor-not-allowed ${isActive ? " bg-secondary" : "text-muted-foreground"}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
<div className="text-muted-foreground px-4 py-0.5 text-xs bg-muted rounded-full">
|
||||
soon
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className={`flex items-center mt-1 gap-3 rounded-lg px-3 py-2 transition-all hover:text-primary ${isActive ? " bg-secondary" : "text-muted-foreground"}`}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
export const LogoutButton: React.FC = () => {
|
||||
return (
|
||||
<button
|
||||
className={` w-full justify-start flex items-center gap-2 rounded-lg py-2 transition-all hover:text-primary text-muted-foreground`}
|
||||
onClick={() => signOut()}
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
Logout
|
||||
</button>
|
||||
);
|
||||
};
|
@@ -55,7 +55,10 @@ export const DeleteTeamInvite: React.FC<{
|
||||
<DialogTitle>Cancel Invite</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to cancel the invite for{" "}
|
||||
<span className="font-semibold text-primary">{invite.email}</span>?
|
||||
<span className="font-semibold text-foreground">
|
||||
{invite.email}
|
||||
</span>
|
||||
?
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex justify-end gap-4 mt-6">
|
||||
|
@@ -83,8 +83,10 @@ export const DeleteTemplate: React.FC<{
|
||||
<DialogTitle>Delete Template</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete{" "}
|
||||
<span className="font-semibold text-primary">{template.name}</span>?
|
||||
You can't reverse this.
|
||||
<span className="font-semibold text-foreground">
|
||||
{template.name}
|
||||
</span>
|
||||
? You can't reverse this.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-2">
|
||||
|
@@ -54,7 +54,10 @@ export const DuplicateTemplate: React.FC<{
|
||||
<DialogTitle>Duplicate Template</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to duplicate{" "}
|
||||
<span className="font-semibold text-primary">{template.name}</span>?
|
||||
<span className="font-semibold text-foreground">
|
||||
{template.name}
|
||||
</span>
|
||||
?
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-2">
|
||||
|
@@ -57,7 +57,7 @@ export default function TemplateList() {
|
||||
<TableRow key={template.id} className="">
|
||||
<TableCell className="font-medium">
|
||||
<Link
|
||||
className="underline underline-offset-4 decoration-dashed text-foreground hover:text-primary"
|
||||
className="underline underline-offset-4 decoration-dashed text-foreground hover:text-foreground"
|
||||
href={`/templates/${template.id}/edit`}
|
||||
>
|
||||
{template.name}
|
||||
|
@@ -24,8 +24,8 @@ export default async function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body className={`font-sans ${inter.variable} app`}>
|
||||
<html lang="en" suppressHydrationWarning className="bg-sidebar-background">
|
||||
<body className={`font-sans ${inter.variable} app bg-sidebar-background`}>
|
||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||
<Toaster />
|
||||
<TRPCReactProvider>{children}</TRPCReactProvider>
|
||||
|
@@ -130,7 +130,7 @@ export default function LoginPage({
|
||||
{isSignup ? "Already have an account?" : "New to Unsend?"}
|
||||
<Link
|
||||
href={isSignup ? "/login" : "/signup"}
|
||||
className=" text-primary hover:underline ml-1"
|
||||
className=" text-foreground hover:underline ml-1"
|
||||
>
|
||||
{isSignup ? "Sign in" : "Create new account"}
|
||||
</Link>
|
||||
|
236
apps/web/src/components/AppSideBar.tsx
Normal file
236
apps/web/src/components/AppSideBar.tsx
Normal file
@@ -0,0 +1,236 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
BookUser,
|
||||
Calendar,
|
||||
Code,
|
||||
Cog,
|
||||
Globe,
|
||||
Home,
|
||||
Inbox,
|
||||
LayoutDashboard,
|
||||
LayoutTemplate,
|
||||
LogOut,
|
||||
Mail,
|
||||
Search,
|
||||
Server,
|
||||
Settings,
|
||||
Volume2,
|
||||
BookOpenText,
|
||||
ChartColumnBig,
|
||||
ChartArea,
|
||||
} from "lucide-react";
|
||||
import { signOut } from "next-auth/react";
|
||||
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from "@unsend/ui/src/sidebar";
|
||||
import Link from "next/link";
|
||||
import { MiniThemeSwitcher, ThemeSwitcher } from "./theme/ThemeSwitcher";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { isSelfHosted } from "~/utils/common";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Badge } from "@unsend/ui/src/badge";
|
||||
|
||||
// General items
|
||||
const generalItems = [
|
||||
{
|
||||
title: "Analytics",
|
||||
url: "/dashboard",
|
||||
icon: ChartColumnBig,
|
||||
},
|
||||
{
|
||||
title: "Emails",
|
||||
url: "/emails",
|
||||
icon: Mail,
|
||||
},
|
||||
{
|
||||
title: "Templates",
|
||||
url: "/templates",
|
||||
icon: LayoutTemplate,
|
||||
},
|
||||
];
|
||||
|
||||
// Marketing items
|
||||
const marketingItems = [
|
||||
{
|
||||
title: "Contacts",
|
||||
url: "/contacts",
|
||||
icon: BookUser,
|
||||
},
|
||||
{
|
||||
title: "Campaigns",
|
||||
url: "/campaigns",
|
||||
icon: Volume2,
|
||||
},
|
||||
];
|
||||
|
||||
// Settings items
|
||||
const settingsItems = [
|
||||
{
|
||||
title: "Domains",
|
||||
url: "/domains",
|
||||
icon: Globe,
|
||||
},
|
||||
{
|
||||
title: "Developer settings",
|
||||
url: "/dev-settings",
|
||||
icon: Code,
|
||||
},
|
||||
{
|
||||
title: "Settings",
|
||||
url: "/settings",
|
||||
icon: Cog,
|
||||
},
|
||||
// TODO: Add conditional logic for Admin item based on isSelfHosted() || session?.user.isAdmin
|
||||
{
|
||||
title: "Admin",
|
||||
url: "/admin",
|
||||
icon: Server,
|
||||
isAdmin: true,
|
||||
isSelfHosted: true,
|
||||
},
|
||||
];
|
||||
|
||||
export function AppSidebar() {
|
||||
const { data: session } = useSession();
|
||||
const { state, open } = useSidebar();
|
||||
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<Sidebar collapsible="icon" variant="sidebar">
|
||||
<SidebarHeader>
|
||||
<SidebarGroupLabel>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg font-semibold text-foreground">
|
||||
Unsend
|
||||
</span>
|
||||
<Badge variant="outline">Beta</Badge>
|
||||
</div>
|
||||
</SidebarGroupLabel>
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>
|
||||
<span>General</span>
|
||||
</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{generalItems.map((item) => {
|
||||
const isActive = pathname?.startsWith(item.url);
|
||||
return (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
tooltip={item.title}
|
||||
isActive={isActive}
|
||||
>
|
||||
<Link href={item.url}>
|
||||
<item.icon />
|
||||
<span>{item.title}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>
|
||||
<span>Marketing</span>
|
||||
</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{marketingItems.map((item) => {
|
||||
const isActive = pathname?.startsWith(item.url);
|
||||
return (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
tooltip={item.title}
|
||||
isActive={isActive}
|
||||
className="text-sidebar-foreground"
|
||||
>
|
||||
<Link href={item.url}>
|
||||
<item.icon />
|
||||
<span>{item.title}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>
|
||||
<span>Settings</span>
|
||||
</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{settingsItems.map((item) => {
|
||||
const isActive = pathname?.startsWith(item.url);
|
||||
|
||||
if (item.isAdmin && !session?.user.isAdmin) {
|
||||
return null;
|
||||
}
|
||||
if (item.isSelfHosted && !isSelfHosted()) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
tooltip={item.title}
|
||||
isActive={isActive}
|
||||
>
|
||||
<Link href={item.url}>
|
||||
<item.icon />
|
||||
<span>{item.title}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
<SidebarFooter>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton onClick={() => signOut()} tooltip="Logout">
|
||||
<LogOut />
|
||||
<span>Logout</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton asChild tooltip="Docs">
|
||||
<Link href="https://docs.unsend.dev" target="_blank">
|
||||
<BookOpenText />
|
||||
<span>Docs</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem className="px-2">
|
||||
<ThemeSwitcher />
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
@@ -123,11 +123,11 @@ export default function JoinTeam({
|
||||
<DialogTitle>Accept Team Invitation</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to join{" "}
|
||||
<span className="font-semibold text-primary">
|
||||
<span className="font-semibold text-foreground">
|
||||
{selectedInvite?.team.name}
|
||||
</span>
|
||||
? You will be added as a{" "}
|
||||
<span className="font-semibold text-primary lowercase">
|
||||
<span className="font-semibold text-foreground lowercase">
|
||||
{selectedInvite?.role.toLowerCase()}
|
||||
</span>
|
||||
.
|
||||
|
@@ -3,19 +3,22 @@ import { Button } from "@unsend/ui/src/button";
|
||||
import { Monitor, Sun, Moon, SunMoonIcon } from "lucide-react";
|
||||
|
||||
export const ThemeSwitcher = () => {
|
||||
const { theme, setTheme, systemTheme } = useTheme();
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<div className="flex gap-2 items-center justify-between w-full">
|
||||
<p className="text-sm text-muted-foreground flex items-center gap-2">
|
||||
<div className="flex gap-2 items-center justify-between">
|
||||
<p className="text-sm text-sidebar-foreground flex items-center gap-2">
|
||||
<SunMoonIcon className="h-4 w-4" />
|
||||
Theme
|
||||
</p>
|
||||
<div className="flex gap-2 border rounded-md p-0.5 ">
|
||||
<div className="flex gap-2 border rounded-md p-1 ">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn("p-0.5 h-5 w-5", theme === "system" ? "bg-muted" : "")}
|
||||
className={cn(
|
||||
"p-0.5 rounded-[0.20rem] h-5 w-5",
|
||||
theme === "system" ? " bg-muted" : ""
|
||||
)}
|
||||
onClick={() => setTheme("system")}
|
||||
>
|
||||
<Monitor className="h-3 w-3" />
|
||||
@@ -24,8 +27,8 @@ export const ThemeSwitcher = () => {
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"p-0.5 h-5 w-5",
|
||||
theme === "light" ? " bg-gray-200" : ""
|
||||
"p-0.5 rounded-[0.20rem] h-5 w-5",
|
||||
theme === "light" ? " bg-muted" : ""
|
||||
)}
|
||||
onClick={() => setTheme("light")}
|
||||
>
|
||||
@@ -34,7 +37,10 @@ export const ThemeSwitcher = () => {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn("p-0.5 h-5 w-5", theme === "dark" ? "bg-muted" : "")}
|
||||
className={cn(
|
||||
"p-0.5 rounded-[0.20rem] h-5 w-5",
|
||||
theme === "dark" ? "bg-muted" : ""
|
||||
)}
|
||||
onClick={() => setTheme("dark")}
|
||||
>
|
||||
<Moon className="h-3 w-3" />
|
||||
@@ -43,3 +49,34 @@ export const ThemeSwitcher = () => {
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const MiniThemeSwitcher = () => {
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
||||
const cycleTheme = () => {
|
||||
if (theme === "light") {
|
||||
setTheme("dark");
|
||||
} else if (theme === "dark") {
|
||||
setTheme("system");
|
||||
} else {
|
||||
setTheme("light");
|
||||
}
|
||||
};
|
||||
|
||||
const renderIcon = () => {
|
||||
switch (theme) {
|
||||
case "light":
|
||||
return <Sun className="h-4 w-4" />;
|
||||
case "dark":
|
||||
return <Moon className="h-4 w-4" />;
|
||||
default:
|
||||
return <Monitor className="h-4 w-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button variant="ghost" size="icon" onClick={cycleTheme}>
|
||||
{renderIcon()}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
@@ -206,7 +206,7 @@ export function ResizableImageTemplate(props: NodeViewProps) {
|
||||
className="flex items-center justify-center opacity-70"
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<Spinner className="w-8 h-8 text-primary" />
|
||||
<Spinner className="w-8 h-8 text-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
|
@@ -180,7 +180,7 @@ export function VariableComponent(props: NodeViewProps) {
|
||||
<button
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center rounded-md text-sm gap-1 ring-offset-white transition-colors",
|
||||
"px-2 border border-gray-300 shadow-sm cursor-pointer text-primary/80"
|
||||
"px-2 border border-gray-300 shadow-sm cursor-pointer text-foreground/80"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
|
@@ -47,6 +47,20 @@ const config = {
|
||||
DEFAULT: "hsl(var(--card))",
|
||||
foreground: "hsl(var(--card-foreground))",
|
||||
},
|
||||
sidebar: {
|
||||
DEFAULT: "hsl(var(--sidebar-background))",
|
||||
foreground: "hsl(var(--sidebar-foreground))",
|
||||
primary: "hsl(var(--sidebar-primary))",
|
||||
"primary-foreground": "hsl(var(--sidebar-primary-foreground))",
|
||||
accent: "hsl(var(--sidebar-accent))",
|
||||
"accent-foreground": "hsl(var(--sidebar-accent-foreground))",
|
||||
border: "hsl(var(--sidebar-border))",
|
||||
ring: "hsl(var(--sidebar-ring))",
|
||||
},
|
||||
success: {
|
||||
DEFAULT: "hsl(var(--success))",
|
||||
foreground: "hsl(var(--success-foreground))",
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: "var(--radius)",
|
||||
|
@@ -9,7 +9,7 @@ const badgeVariants = cva(
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
"border-transparent bg-primary text-foreground-foreground hover:bg-primary/80",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
|
@@ -21,7 +21,7 @@ const DialogOverlay = React.forwardRef<
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
"fixed inset-0 z-50 bg-background/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 backdrop-blur",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -38,7 +38,7 @@ const DialogContent = React.forwardRef<
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
"fixed left-[50%] top-[50%] rounded-2xl z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border-2 bg-popover p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
21
packages/ui/src/hooks/use-mobile.tsx
Normal file
21
packages/ui/src/hooks/use-mobile.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import * as React from "react";
|
||||
|
||||
const MOBILE_BREAKPOINT = 768;
|
||||
|
||||
export function useIsMobile() {
|
||||
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(
|
||||
undefined
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
|
||||
const onChange = () => {
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
||||
};
|
||||
mql.addEventListener("change", onChange);
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
||||
return () => mql.removeEventListener("change", onChange);
|
||||
}, []);
|
||||
|
||||
return !!isMobile;
|
||||
}
|
@@ -75,7 +75,7 @@ const SelectContent = React.forwardRef<
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-lg border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
|
@@ -21,7 +21,10 @@ const SheetOverlay = React.forwardRef<
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Overlay
|
||||
className={cn("fixed inset-0 z-50 bg-black/80", className)}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-background/50 backdrop-blur",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
asChild
|
||||
|
773
packages/ui/src/sidebar.tsx
Normal file
773
packages/ui/src/sidebar.tsx
Normal file
@@ -0,0 +1,773 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { VariantProps, cva } from "class-variance-authority";
|
||||
import { PanelLeft } from "lucide-react";
|
||||
|
||||
import { useIsMobile } from "./hooks/use-mobile";
|
||||
import { cn } from "../lib/utils";
|
||||
import { Button } from "./button";
|
||||
import { Input } from "./input";
|
||||
import { Separator } from "./separator";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "./sheet";
|
||||
import { Skeleton } from "./skeleton";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "./tooltip";
|
||||
|
||||
const SIDEBAR_COOKIE_NAME = "sidebar_state";
|
||||
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
|
||||
const SIDEBAR_WIDTH = "16rem";
|
||||
const SIDEBAR_WIDTH_MOBILE = "18rem";
|
||||
const SIDEBAR_WIDTH_ICON = "3rem";
|
||||
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
|
||||
|
||||
type SidebarContextProps = {
|
||||
state: "expanded" | "collapsed";
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
openMobile: boolean;
|
||||
setOpenMobile: (open: boolean) => void;
|
||||
isMobile: boolean;
|
||||
toggleSidebar: () => void;
|
||||
};
|
||||
|
||||
const SidebarContext = React.createContext<SidebarContextProps | null>(null);
|
||||
|
||||
function useSidebar() {
|
||||
const context = React.useContext(SidebarContext);
|
||||
if (!context) {
|
||||
throw new Error("useSidebar must be used within a SidebarProvider.");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
const SidebarProvider = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div"> & {
|
||||
defaultOpen?: boolean;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
>(
|
||||
(
|
||||
{
|
||||
defaultOpen = true,
|
||||
open: openProp,
|
||||
onOpenChange: setOpenProp,
|
||||
className,
|
||||
style,
|
||||
children,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const isMobile = useIsMobile();
|
||||
const [openMobile, setOpenMobile] = React.useState(false);
|
||||
|
||||
// This is the internal state of the sidebar.
|
||||
// We use openProp and setOpenProp for control from outside the component.
|
||||
const [_open, _setOpen] = React.useState(defaultOpen);
|
||||
const open = openProp ?? _open;
|
||||
const setOpen = React.useCallback(
|
||||
(value: boolean | ((value: boolean) => boolean)) => {
|
||||
const openState = typeof value === "function" ? value(open) : value;
|
||||
if (setOpenProp) {
|
||||
setOpenProp(openState);
|
||||
} else {
|
||||
_setOpen(openState);
|
||||
}
|
||||
|
||||
// This sets the cookie to keep the sidebar state.
|
||||
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
|
||||
},
|
||||
[setOpenProp, open]
|
||||
);
|
||||
|
||||
// Helper to toggle the sidebar.
|
||||
const toggleSidebar = React.useCallback(() => {
|
||||
return isMobile
|
||||
? setOpenMobile((open) => !open)
|
||||
: setOpen((open) => !open);
|
||||
}, [isMobile, setOpen, setOpenMobile]);
|
||||
|
||||
// Adds a keyboard shortcut to toggle the sidebar.
|
||||
React.useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (
|
||||
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
|
||||
(event.metaKey || event.ctrlKey)
|
||||
) {
|
||||
event.preventDefault();
|
||||
toggleSidebar();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [toggleSidebar]);
|
||||
|
||||
// We add a state so that we can do data-state="expanded" or "collapsed".
|
||||
// This makes it easier to style the sidebar with Tailwind classes.
|
||||
const state = open ? "expanded" : "collapsed";
|
||||
|
||||
const contextValue = React.useMemo<SidebarContextProps>(
|
||||
() => ({
|
||||
state,
|
||||
open,
|
||||
setOpen,
|
||||
isMobile,
|
||||
openMobile,
|
||||
setOpenMobile,
|
||||
toggleSidebar,
|
||||
}),
|
||||
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
|
||||
);
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value={contextValue}>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<div
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": SIDEBAR_WIDTH,
|
||||
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
|
||||
...style,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={cn(
|
||||
"group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</SidebarContext.Provider>
|
||||
);
|
||||
}
|
||||
);
|
||||
SidebarProvider.displayName = "SidebarProvider";
|
||||
|
||||
const Sidebar = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div"> & {
|
||||
side?: "left" | "right";
|
||||
variant?: "sidebar" | "floating" | "inset";
|
||||
collapsible?: "offcanvas" | "icon" | "none";
|
||||
}
|
||||
>(
|
||||
(
|
||||
{
|
||||
side = "left",
|
||||
variant = "sidebar",
|
||||
collapsible = "offcanvas",
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
|
||||
|
||||
if (collapsible === "none") {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full w-[--sidebar-width] flex-col bg-sidebar text-sidebar-foreground",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
|
||||
<SheetContent
|
||||
data-sidebar="sidebar"
|
||||
data-mobile="true"
|
||||
className="w-[--sidebar-width] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
side={side}
|
||||
>
|
||||
<SheetHeader className="sr-only">
|
||||
<SheetTitle>Sidebar</SheetTitle>
|
||||
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="flex h-full w-full flex-col">{children}</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="group peer hidden text-sidebar-foreground md:block"
|
||||
data-state={state}
|
||||
data-collapsible={state === "collapsed" ? collapsible : ""}
|
||||
data-variant={variant}
|
||||
data-side={side}
|
||||
>
|
||||
{/* This is what handles the sidebar gap on desktop */}
|
||||
<div
|
||||
className={cn(
|
||||
"relative w-[--sidebar-width] bg-transparent transition-[width] duration-200 ease-linear",
|
||||
"group-data-[collapsible=offcanvas]:w-0",
|
||||
"group-data-[side=right]:rotate-180",
|
||||
variant === "floating" || variant === "inset"
|
||||
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4))]"
|
||||
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon]"
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"fixed inset-y-0 z-10 hidden h-svh w-[--sidebar-width] transition-[left,right,width] duration-200 ease-linear md:flex",
|
||||
side === "left"
|
||||
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
|
||||
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
|
||||
// Adjust the padding for floating and inset variants.
|
||||
variant === "floating" || variant === "inset"
|
||||
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4)_+2px)]"
|
||||
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon] group-data-[side=left]:border-r group-data-[side=right]:border-l",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
data-sidebar="sidebar"
|
||||
className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
Sidebar.displayName = "Sidebar";
|
||||
|
||||
const SidebarTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof Button>,
|
||||
React.ComponentProps<typeof Button>
|
||||
>(({ className, onClick, ...props }, ref) => {
|
||||
const { toggleSidebar } = useSidebar();
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
data-sidebar="trigger"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn("h-7 w-7", className)}
|
||||
onClick={(event) => {
|
||||
onClick?.(event);
|
||||
toggleSidebar();
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<PanelLeft />
|
||||
<span className="sr-only">Toggle Sidebar</span>
|
||||
</Button>
|
||||
);
|
||||
});
|
||||
SidebarTrigger.displayName = "SidebarTrigger";
|
||||
|
||||
const SidebarRail = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<"button">
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { toggleSidebar } = useSidebar();
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
data-sidebar="rail"
|
||||
aria-label="Toggle Sidebar"
|
||||
tabIndex={-1}
|
||||
onClick={toggleSidebar}
|
||||
title="Toggle Sidebar"
|
||||
className={cn(
|
||||
"absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex",
|
||||
"[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize",
|
||||
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
|
||||
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full group-data-[collapsible=offcanvas]:hover:bg-sidebar",
|
||||
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
|
||||
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
SidebarRail.displayName = "SidebarRail";
|
||||
|
||||
const SidebarInset = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"main">
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<main
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full flex-1 flex-col bg-background",
|
||||
"md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
SidebarInset.displayName = "SidebarInset";
|
||||
|
||||
const SidebarInput = React.forwardRef<
|
||||
React.ElementRef<typeof Input>,
|
||||
React.ComponentProps<typeof Input>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<Input
|
||||
ref={ref}
|
||||
data-sidebar="input"
|
||||
className={cn(
|
||||
"h-8 w-full bg-background shadow-none focus-visible:ring-2 focus-visible:ring-sidebar-ring",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
SidebarInput.displayName = "SidebarInput";
|
||||
|
||||
const SidebarHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div">
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
data-sidebar="header"
|
||||
className={cn("flex flex-col gap-2 p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
SidebarHeader.displayName = "SidebarHeader";
|
||||
|
||||
const SidebarFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div">
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
data-sidebar="footer"
|
||||
className={cn("flex flex-col gap-2 p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
SidebarFooter.displayName = "SidebarFooter";
|
||||
|
||||
const SidebarSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof Separator>,
|
||||
React.ComponentProps<typeof Separator>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<Separator
|
||||
ref={ref}
|
||||
data-sidebar="separator"
|
||||
className={cn("mx-2 w-auto bg-sidebar-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
SidebarSeparator.displayName = "SidebarSeparator";
|
||||
|
||||
const SidebarContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div">
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
data-sidebar="content"
|
||||
className={cn(
|
||||
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
SidebarContent.displayName = "SidebarContent";
|
||||
|
||||
const SidebarGroup = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div">
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
data-sidebar="group"
|
||||
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
SidebarGroup.displayName = "SidebarGroup";
|
||||
|
||||
const SidebarGroupLabel = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div"> & { asChild?: boolean }
|
||||
>(({ className, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "div";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-sidebar="group-label"
|
||||
className={cn(
|
||||
"flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-none ring-sidebar-ring transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
SidebarGroupLabel.displayName = "SidebarGroupLabel";
|
||||
|
||||
const SidebarGroupAction = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<"button"> & { asChild?: boolean }
|
||||
>(({ className, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-sidebar="group-action"
|
||||
className={cn(
|
||||
"absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
// Increases the hit area of the button on mobile.
|
||||
"after:absolute after:-inset-2 after:md:hidden",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
SidebarGroupAction.displayName = "SidebarGroupAction";
|
||||
|
||||
const SidebarGroupContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
data-sidebar="group-content"
|
||||
className={cn("w-full text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SidebarGroupContent.displayName = "SidebarGroupContent";
|
||||
|
||||
const SidebarMenu = React.forwardRef<
|
||||
HTMLUListElement,
|
||||
React.ComponentProps<"ul">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ul
|
||||
ref={ref}
|
||||
data-sidebar="menu"
|
||||
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SidebarMenu.displayName = "SidebarMenu";
|
||||
|
||||
const SidebarMenuItem = React.forwardRef<
|
||||
HTMLLIElement,
|
||||
React.ComponentProps<"li">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<li
|
||||
ref={ref}
|
||||
data-sidebar="menu-item"
|
||||
className={cn("group/menu-item relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SidebarMenuItem.displayName = "SidebarMenuItem";
|
||||
|
||||
const sidebarMenuButtonVariants = cva(
|
||||
"peer/menu-button flex w-full items-center text-sidebar-foreground gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||
outline:
|
||||
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
|
||||
},
|
||||
size: {
|
||||
default: "h-8 text-sm",
|
||||
sm: "h-7 text-xs",
|
||||
lg: "h-12 text-sm group-data-[collapsible=icon]:!p-0",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const SidebarMenuButton = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<"button"> & {
|
||||
asChild?: boolean;
|
||||
isActive?: boolean;
|
||||
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
|
||||
} & VariantProps<typeof sidebarMenuButtonVariants>
|
||||
>(
|
||||
(
|
||||
{
|
||||
asChild = false,
|
||||
isActive = false,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
tooltip,
|
||||
className,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
const { isMobile, state } = useSidebar();
|
||||
|
||||
const button = (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-sidebar="menu-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
if (!tooltip) {
|
||||
return button;
|
||||
}
|
||||
|
||||
if (typeof tooltip === "string") {
|
||||
tooltip = {
|
||||
children: tooltip,
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
align="center"
|
||||
hidden={state !== "collapsed" || isMobile}
|
||||
{...tooltip}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
);
|
||||
SidebarMenuButton.displayName = "SidebarMenuButton";
|
||||
|
||||
const SidebarMenuAction = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<"button"> & {
|
||||
asChild?: boolean;
|
||||
showOnHover?: boolean;
|
||||
}
|
||||
>(({ className, asChild = false, showOnHover = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-sidebar="menu-action"
|
||||
className={cn(
|
||||
"absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
// Increases the hit area of the button on mobile.
|
||||
"after:absolute after:-inset-2 after:md:hidden",
|
||||
"peer-data-[size=sm]/menu-button:top-1",
|
||||
"peer-data-[size=default]/menu-button:top-1.5",
|
||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
showOnHover &&
|
||||
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
SidebarMenuAction.displayName = "SidebarMenuAction";
|
||||
|
||||
const SidebarMenuBadge = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
data-sidebar="menu-badge"
|
||||
className={cn(
|
||||
"pointer-events-none absolute right-1 flex h-5 min-w-5 select-none items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground",
|
||||
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
|
||||
"peer-data-[size=sm]/menu-button:top-1",
|
||||
"peer-data-[size=default]/menu-button:top-1.5",
|
||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SidebarMenuBadge.displayName = "SidebarMenuBadge";
|
||||
|
||||
const SidebarMenuSkeleton = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div"> & {
|
||||
showIcon?: boolean;
|
||||
}
|
||||
>(({ className, showIcon = false, ...props }, ref) => {
|
||||
// Random width between 50 to 90%.
|
||||
const width = React.useMemo(() => {
|
||||
return `${Math.floor(Math.random() * 40) + 50}%`;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
data-sidebar="menu-skeleton"
|
||||
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
|
||||
{...props}
|
||||
>
|
||||
{showIcon && (
|
||||
<Skeleton
|
||||
className="size-4 rounded-md"
|
||||
data-sidebar="menu-skeleton-icon"
|
||||
/>
|
||||
)}
|
||||
<Skeleton
|
||||
className="h-4 max-w-[--skeleton-width] flex-1"
|
||||
data-sidebar="menu-skeleton-text"
|
||||
style={
|
||||
{
|
||||
"--skeleton-width": width,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
SidebarMenuSkeleton.displayName = "SidebarMenuSkeleton";
|
||||
|
||||
const SidebarMenuSub = React.forwardRef<
|
||||
HTMLUListElement,
|
||||
React.ComponentProps<"ul">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ul
|
||||
ref={ref}
|
||||
data-sidebar="menu-sub"
|
||||
className={cn(
|
||||
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SidebarMenuSub.displayName = "SidebarMenuSub";
|
||||
|
||||
const SidebarMenuSubItem = React.forwardRef<
|
||||
HTMLLIElement,
|
||||
React.ComponentProps<"li">
|
||||
>(({ ...props }, ref) => <li ref={ref} {...props} />);
|
||||
SidebarMenuSubItem.displayName = "SidebarMenuSubItem";
|
||||
|
||||
const SidebarMenuSubButton = React.forwardRef<
|
||||
HTMLAnchorElement,
|
||||
React.ComponentProps<"a"> & {
|
||||
asChild?: boolean;
|
||||
size?: "sm" | "md";
|
||||
isActive?: boolean;
|
||||
}
|
||||
>(({ asChild = false, size = "md", isActive, className, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "a";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-sidebar="menu-sub-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
|
||||
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
|
||||
size === "sm" && "text-xs",
|
||||
size === "md" && "text-sm",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
SidebarMenuSubButton.displayName = "SidebarMenuSubButton";
|
||||
|
||||
export {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupAction,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarInput,
|
||||
SidebarInset,
|
||||
SidebarMenu,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuBadge,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSkeleton,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
SidebarProvider,
|
||||
SidebarRail,
|
||||
SidebarSeparator,
|
||||
SidebarTrigger,
|
||||
useSidebar,
|
||||
};
|
15
packages/ui/src/skeleton.tsx
Normal file
15
packages/ui/src/skeleton.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
function Skeleton({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Skeleton };
|
@@ -19,7 +19,7 @@ const Switch = React.forwardRef<
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
"pointer-events-none block h-5 w-5 rounded-full bg-accent-foreground shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
|
||||
"pointer-events-none block h-5 w-5 rounded-full bg-primary/50 shadow-lg ring-0 transition-transform data-[state=checked]:bg-background/90 data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
|
@@ -5,31 +5,34 @@
|
||||
@layer base {
|
||||
:root,
|
||||
.light {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
--background: 220 2% 96%;
|
||||
--foreground: 234 16% 35%;
|
||||
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222.2 84% 4.9%;
|
||||
|
||||
--popover: 0 0% 100%;
|
||||
--popover: 220 2% 96%;
|
||||
--popover-foreground: 222.2 84% 4.9%;
|
||||
|
||||
--primary: 222.2 47.4% 11.2%;
|
||||
--primary: 200 65% 14%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
|
||||
--secondary: 210 40% 96.1%;
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted: 240 11% 88%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
|
||||
--accent: 210 40% 96.1%;
|
||||
--accent: 240 11% 88%;
|
||||
--accent-foreground: 222.2 47.4% 11.2%;
|
||||
|
||||
--destructive: 0 84% 60%;
|
||||
--destructive: 347 62% 55%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
|
||||
--border: 220 14% 96%;
|
||||
--success: 142 49% 44%;
|
||||
--success-foreground: 210 40% 98%;
|
||||
|
||||
--border: 220 21% 89%;
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
--ring: 222.2 84% 4.9%;
|
||||
|
||||
@@ -40,34 +43,46 @@
|
||||
--chart-3: 197 37% 24%;
|
||||
--chart-4: 43 74% 66%;
|
||||
--chart-5: 27 87% 67%;
|
||||
|
||||
--sidebar-background: 225 3% 94%;
|
||||
--sidebar-foreground: 240 5.3% 26.1%;
|
||||
--sidebar-primary: 240 5.9% 10%;
|
||||
--sidebar-primary-foreground: 0 0% 98%;
|
||||
--sidebar-accent: 240 11% 88%;
|
||||
--sidebar-accent-foreground: 240 5.9% 10%;
|
||||
--sidebar-border: 240 11% 88%;
|
||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 223 3% 3%;
|
||||
--foreground: 210 3% 82%;
|
||||
--background: 240 21% 15%;
|
||||
--foreground: 226 64% 88%;
|
||||
|
||||
--card: 222.2 84% 4.9%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
|
||||
--popover: 222.2 84% 4.9%;
|
||||
--popover: 240 21% 15%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
|
||||
--primary: 210 40% 98%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
--primary: 220 23% 95%;
|
||||
--primary-foreground: 240 23% 9%;
|
||||
|
||||
--secondary: 217.2 32.6% 17.5%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
|
||||
--muted: 217.2 32.6% 17.5%;
|
||||
--muted: 237 16% 23%;
|
||||
--muted-foreground: 215 20.2% 65.1%;
|
||||
|
||||
--accent: 217.2 32.6% 17.5%;
|
||||
--accent: 237 16% 23%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
|
||||
--destructive: 0 68% 41%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--destructive: 343 81% 75%;
|
||||
--destructive-foreground: 240 21% 15%;
|
||||
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
--success: 115 54% 76%;
|
||||
--success-foreground: 210 40% 98%;
|
||||
|
||||
--border: 237 16% 23%;
|
||||
--input: 217.2 32.6% 17.5%;
|
||||
--ring: 217.2 32.6% 17.5%;
|
||||
|
||||
@@ -76,6 +91,15 @@
|
||||
--chart-3: 30 80% 55%;
|
||||
--chart-4: 280 65% 60%;
|
||||
--chart-5: 340 75% 55%;
|
||||
|
||||
--sidebar-background: 240 21% 12%;
|
||||
--sidebar-foreground: 226 64% 88%;
|
||||
--sidebar-primary: 224.3 76.3% 48%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 237 17% 20%;
|
||||
--sidebar-accent-foreground: 240 4.8% 95.9%;
|
||||
--sidebar-border: 240 21% 15%;
|
||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||
}
|
||||
}
|
||||
|
||||
|
Reference in New Issue
Block a user