feat: add new theme (#157)

This commit is contained in:
KM Koushik
2025-05-06 22:00:50 +10:00
committed by GitHub
parent 2de7147cdf
commit b394c78be2
40 changed files with 1236 additions and 494 deletions

View File

@@ -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 users 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 todos, 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.

View File

@@ -1,6 +1,7 @@
const config = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@@ -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" ? (

View File

@@ -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>
// );
// }

View File

@@ -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">

View File

@@ -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">

View File

@@ -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">

View File

@@ -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>

View File

@@ -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.

View File

@@ -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>

View File

@@ -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>
);
}

View File

@@ -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>

View File

@@ -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">

View File

@@ -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>

View File

@@ -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}>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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}

View File

@@ -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>
);
};

View File

@@ -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">

View File

@@ -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">

View File

@@ -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">

View File

@@ -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}

View File

@@ -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>

View File

@@ -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>

View 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>
);
}

View File

@@ -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>
.

View File

@@ -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>
);
};

View File

@@ -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>
) : (

View File

@@ -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();

View File

@@ -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)",

View File

@@ -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:

View File

@@ -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}

View 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;
}

View File

@@ -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

View File

@@ -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
View 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,
};

View 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 };

View File

@@ -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>

View File

@@ -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%;
}
}