[rewrite] Updated frontend structure and played with custom routes

This commit is contained in:
Thastertyn committed 2025-02-21 14:51:17 +01:00
1 parent 43063dcf4e
commit 073f01d070
243 files changed
+11778 -11940

No files matched your search

+110
View File
@@ -0,0 +1,110 @@
import {
IconBrandDiscord,
IconBrandDocker,
IconBrandFigma,
IconBrandGithub,
IconBrandGitlab,
IconBrandGmail,
IconBrandMedium,
IconBrandNotion,
IconBrandSkype,
IconBrandSlack,
IconBrandStripe,
IconBrandTelegram,
IconBrandTrello,
IconBrandWhatsapp,
IconBrandZoom
} from "@tabler/icons-react";
export const apps = [
{
name: "Telegram",
logo: <IconBrandTelegram />,
connected: false,
desc: "Connect with Telegram for real-time communication."
},
{
name: "Notion",
logo: <IconBrandNotion />,
connected: true,
desc: "Effortlessly sync Notion pages for seamless collaboration."
},
{
name: "Figma",
logo: <IconBrandFigma />,
connected: true,
desc: "View and collaborate on Figma designs in one place."
},
{
name: "Trello",
logo: <IconBrandTrello />,
connected: false,
desc: "Sync Trello cards for streamlined project management."
},
{
name: "Slack",
logo: <IconBrandSlack />,
connected: false,
desc: "Integrate Slack for efficient team communication"
},
{
name: "Zoom",
logo: <IconBrandZoom />,
connected: true,
desc: "Host Zoom meetings directly from the dashboard."
},
{
name: "Stripe",
logo: <IconBrandStripe />,
connected: false,
desc: "Easily manage Stripe transactions and payments."
},
{
name: "Gmail",
logo: <IconBrandGmail />,
connected: true,
desc: "Access and manage Gmail messages effortlessly."
},
{
name: "Medium",
logo: <IconBrandMedium />,
connected: false,
desc: "Explore and share Medium stories on your dashboard."
},
{
name: "Skype",
logo: <IconBrandSkype />,
connected: false,
desc: "Connect with Skype contacts seamlessly."
},
{
name: "Docker",
logo: <IconBrandDocker />,
connected: false,
desc: "Effortlessly manage Docker containers on your dashboard."
},
{
name: "GitHub",
logo: <IconBrandGithub />,
connected: false,
desc: "Streamline code management with GitHub integration."
},
{
name: "GitLab",
logo: <IconBrandGitlab />,
connected: false,
desc: "Efficiently manage code projects with GitLab integration."
},
{
name: "Discord",
logo: <IconBrandDiscord />,
connected: false,
desc: "Connect with Discord for seamless team communication."
},
{
name: "WhatsApp",
logo: <IconBrandWhatsapp />,
connected: false,
desc: "Easily integrate WhatsApp for direct messaging."
}
];
+141
View File
@@ -0,0 +1,141 @@
import { useState } from "react";
import {
IconAdjustmentsHorizontal,
IconSortAscendingLetters,
IconSortDescendingLetters
} from "@tabler/icons-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Header } from "@/components/layout/header";
import { Main } from "@/components/layout/main";
import { ProfileDropdown } from "@/components/profile-dropdown";
import { Search } from "@/components/search";
import { ThemeSwitch } from "@/components/theme-switch";
import { apps } from "./data/apps";
const appText = new Map<string, string>([
["all", "All Apps"],
["connected", "Connected"],
["notConnected", "Not Connected"]
]);
export default function Apps() {
const [sort, setSort] = useState("ascending");
const [appType, setAppType] = useState("all");
const [searchTerm, setSearchTerm] = useState("");
const filteredApps = apps
.sort((a, b) =>
sort === "ascending"
? a.name.localeCompare(b.name)
: b.name.localeCompare(a.name)
)
.filter((app) =>
appType === "connected"
? app.connected
: appType === "notConnected"
? !app.connected
: true
)
.filter((app) => app.name.toLowerCase().includes(searchTerm.toLowerCase()));
return (
<>
{/* ===== Top Heading ===== */}
<Header>
<Search />
<div className="ml-auto flex items-center gap-4">
<ThemeSwitch />
<ProfileDropdown />
</div>
</Header>
{/* ===== Content ===== */}
<Main fixed>
<div>
<h1 className="text-2xl font-bold tracking-tight">
App Integrations
</h1>
<p className="text-muted-foreground">
Here&apos;s a list of your apps for the integration!
</p>
</div>
<div className="my-4 flex items-end justify-between sm:my-0 sm:items-center">
<div className="flex flex-col gap-4 sm:my-4 sm:flex-row">
<Input
placeholder="Filter apps..."
className="h-9 w-40 lg:w-[250px]"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
<Select value={appType} onValueChange={setAppType}>
<SelectTrigger className="w-36">
<SelectValue>{appText.get(appType)}</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Apps</SelectItem>
<SelectItem value="connected">Connected</SelectItem>
<SelectItem value="notConnected">Not Connected</SelectItem>
</SelectContent>
</Select>
</div>
<Select value={sort} onValueChange={setSort}>
<SelectTrigger className="w-16">
<SelectValue>
<IconAdjustmentsHorizontal size={18} />
</SelectValue>
</SelectTrigger>
<SelectContent align="end">
<SelectItem value="ascending">
<div className="flex items-center gap-4">
<IconSortAscendingLetters size={16} />
<span>Ascending</span>
</div>
</SelectItem>
<SelectItem value="descending">
<div className="flex items-center gap-4">
<IconSortDescendingLetters size={16} />
<span>Descending</span>
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
<Separator className="shadow" />
<ul className="faded-bottom no-scrollbar grid gap-4 overflow-auto pb-16 pt-4 md:grid-cols-2 lg:grid-cols-3">
{filteredApps.map((app) => (
<li
key={app.name}
className="rounded-lg border p-4 hover:shadow-md">
<div className="mb-8 flex items-center justify-between">
<div
className={`flex size-10 items-center justify-center rounded-lg bg-muted p-2`}>
{app.logo}
</div>
<Button
variant="outline"
size="sm"
className={`${app.connected ? "border border-blue-300 bg-blue-50 hover:bg-blue-100 dark:border-blue-700 dark:bg-blue-950 dark:hover:bg-blue-900" : ""}`}>
{app.connected ? "Connected" : "Connect"}
</Button>
</div>
<div>
<h2 className="mb-1 font-semibold">{app.name}</h2>
<p className="line-clamp-2 text-gray-500">{app.desc}</p>
</div>
</li>
))}
</ul>
</Main>
</>
);
}
+27
View File
@@ -0,0 +1,27 @@
interface Props {
children: React.ReactNode;
}
export default function AuthLayout({ children }: Props) {
return (
<div className="container grid h-svh flex-col items-center justify-center bg-primary-foreground lg:max-w-none lg:px-0">
<div className="mx-auto flex w-full flex-col justify-center space-y-2 sm:w-[480px] lg:p-8">
<div className="mb-4 flex items-center justify-center">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="mr-2 h-6 w-6">
<path d="M15 6v12a3 3 0 1 0 3-3H6a3 3 0 1 0 3 3V6a3 3 0 1 0-3 3h12a3 3 0 1 0-3-3" />
</svg>
<h1 className="text-xl font-medium">Shadcn Admin</h1>
</div>
{children}
</div>
</div>
);
}
@@ -0,0 +1,70 @@
import { HTMLAttributes, useState } from "react";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
type ForgotFormProps = HTMLAttributes<HTMLDivElement>;
const formSchema = z.object({
email: z
.string()
.min(1, { message: "Please enter your email" })
.email({ message: "Invalid email address" })
});
export function ForgotForm({ className, ...props }: ForgotFormProps) {
const [isLoading, setIsLoading] = useState(false);
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: { email: "" }
});
function onSubmit(data: z.infer<typeof formSchema>) {
setIsLoading(true);
// eslint-disable-next-line no-console
console.log(data);
setTimeout(() => {
setIsLoading(false);
}, 3000);
}
return (
<div className={cn("grid gap-6", className)} {...props}>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className="grid gap-2">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem className="space-y-1">
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="name@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button className="mt-2" disabled={isLoading}>
Continue
</Button>
</div>
</form>
</Form>
</div>
);
}
@@ -0,0 +1,32 @@
import { Link } from "@tanstack/react-router";
import { Card } from "@/components/ui/card";
import AuthLayout from "../auth-layout";
import { ForgotForm } from "./components/forgot-password-form";
export default function ForgotPassword() {
return (
<AuthLayout>
<Card className="p-6">
<div className="mb-2 flex flex-col space-y-2 text-left">
<h1 className="text-md font-semibold tracking-tight">
Forgot Password
</h1>
<p className="text-sm text-muted-foreground">
Enter your registered email and <br /> we will send you a link to
reset your password.
</p>
</div>
<ForgotForm />
<p className="mt-4 px-8 text-center text-sm text-muted-foreground">
Don't have an account?{" "}
<Link
to="/sign-up"
className="underline underline-offset-4 hover:text-primary">
Sign up
</Link>
.
</p>
</Card>
</AuthLayout>
);
}
@@ -0,0 +1,94 @@
import { HTMLAttributes, useState } from "react";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useNavigate } from "@tanstack/react-router";
import { cn } from "@/lib/utils";
import { toast } from "@/hooks/use-toast";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormMessage
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import { PinInput, PinInputField } from "@/components/pin-input";
type OtpFormProps = HTMLAttributes<HTMLDivElement>;
const formSchema = z.object({
otp: z.string().min(1, { message: "Please enter your otp code." })
});
export function OtpForm({ className, ...props }: OtpFormProps) {
const navigate = useNavigate();
const [isLoading, setIsLoading] = useState(false);
const [disabledBtn, setDisabledBtn] = useState(true);
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: { otp: "" }
});
function onSubmit(data: z.infer<typeof formSchema>) {
setIsLoading(true);
toast({
title: "You submitted the following values:",
description: (
<pre className="mt-2 w-[340px] rounded-md bg-slate-950 p-4">
<code className="text-white">{JSON.stringify(data, null, 2)}</code>
</pre>
)
});
setTimeout(() => {
setIsLoading(false);
navigate({ to: "/" });
}, 1000);
}
return (
<div className={cn("grid gap-6", className)} {...props}>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className="grid gap-2">
<FormField
control={form.control}
name="otp"
render={({ field }) => (
<FormItem className="space-y-1">
<FormControl>
<PinInput
{...field}
className="flex h-10 justify-between"
onComplete={() => setDisabledBtn(false)}
onIncomplete={() => setDisabledBtn(true)}>
{Array.from({ length: 7 }, (_, i) => {
if (i === 3)
return <Separator key={i} orientation="vertical" />;
return (
<PinInputField
key={i}
component={Input}
className={`${form.getFieldState("otp").invalid ? "border-red-500" : ""}`}
/>
);
})}
</PinInput>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button className="mt-2" disabled={disabledBtn || isLoading}>
Verify
</Button>
</div>
</form>
</Form>
</div>
);
}
+32
View File
@@ -0,0 +1,32 @@
import { Link } from "@tanstack/react-router";
import { Card } from "@/components/ui/card";
import AuthLayout from "../auth-layout";
import { OtpForm } from "./components/otp-form";
export default function Otp() {
return (
<AuthLayout>
<Card className="p-6">
<div className="mb-2 flex flex-col space-y-2 text-left">
<h1 className="text-md font-semibold tracking-tight">
Two-factor Authentication
</h1>
<p className="text-sm text-muted-foreground">
Please enter the authentication code. <br /> We have sent the
authentication code to your email.
</p>
</div>
<OtpForm />
<p className="mt-4 px-8 text-center text-sm text-muted-foreground">
Haven't received it?{" "}
<Link
to="/sign-in"
className="underline underline-offset-4 hover:text-primary">
Resend a new code.
</Link>
.
</p>
</Card>
</AuthLayout>
);
}
@@ -0,0 +1,132 @@
import { HTMLAttributes, useState } from "react";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Link } from "@tanstack/react-router";
import { IconBrandFacebook, IconBrandGithub } from "@tabler/icons-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { PasswordInput } from "@/components/password-input";
type UserAuthFormProps = HTMLAttributes<HTMLDivElement>;
const formSchema = z.object({
email: z
.string()
.min(1, { message: "Please enter your email" })
.email({ message: "Invalid email address" }),
password: z
.string()
.min(1, {
message: "Please enter your password"
})
.min(7, {
message: "Password must be at least 7 characters long"
})
});
export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
const [isLoading, setIsLoading] = useState(false);
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
email: "",
password: ""
}
});
function onSubmit(data: z.infer<typeof formSchema>) {
setIsLoading(true);
// eslint-disable-next-line no-console
console.log(data);
setTimeout(() => {
setIsLoading(false);
}, 3000);
}
return (
<div className={cn("grid gap-6", className)} {...props}>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className="grid gap-2">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem className="space-y-1">
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="name@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem className="space-y-1">
<div className="flex items-center justify-between">
<FormLabel>Password</FormLabel>
<Link
to="/forgot-password"
className="text-sm font-medium text-muted-foreground hover:opacity-75">
Forgot password?
</Link>
</div>
<FormControl>
<PasswordInput placeholder="********" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button className="mt-2" disabled={isLoading}>
Login
</Button>
<div className="relative my-2">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">
Or continue with
</span>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
className="w-full"
type="button"
disabled={isLoading}>
<IconBrandGithub className="h-4 w-4" /> GitHub
</Button>
<Button
variant="outline"
className="w-full"
type="button"
disabled={isLoading}>
<IconBrandFacebook className="h-4 w-4" /> Facebook
</Button>
</div>
</div>
</form>
</Form>
</div>
);
}
+35
View File
@@ -0,0 +1,35 @@
import { Card } from "@/components/ui/card";
import AuthLayout from "../auth-layout";
import { UserAuthForm } from "./components/user-auth-form";
export default function SignIn() {
return (
<AuthLayout>
<Card className="p-6">
<div className="flex flex-col space-y-2 text-left">
<h1 className="text-2xl font-semibold tracking-tight">Login</h1>
<p className="text-sm text-muted-foreground">
Enter your email and password below <br />
to log into your account
</p>
</div>
<UserAuthForm />
<p className="mt-4 px-8 text-center text-sm text-muted-foreground">
By clicking login, you agree to our{" "}
<a
href="/terms"
className="underline underline-offset-4 hover:text-primary">
Terms of Service
</a>{" "}
and{" "}
<a
href="/privacy"
className="underline underline-offset-4 hover:text-primary">
Privacy Policy
</a>
.
</p>
</Card>
</AuthLayout>
);
}
@@ -0,0 +1,72 @@
import ViteLogo from "@/assets/vite.svg";
import { UserAuthForm } from "./components/user-auth-form";
export default function SignIn2() {
return (
<div className="container relative grid h-svh flex-col items-center justify-center lg:max-w-none lg:grid-cols-2 lg:px-0">
<div className="relative hidden h-full flex-col bg-muted p-10 text-white dark:border-r lg:flex">
<div className="absolute inset-0 bg-zinc-900" />
<div className="relative z-20 flex items-center text-lg font-medium">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="mr-2 h-6 w-6">
<path d="M15 6v12a3 3 0 1 0 3-3H6a3 3 0 1 0 3 3V6a3 3 0 1 0-3 3h12a3 3 0 1 0-3-3" />
</svg>
Shadcn Admin
</div>
<img
src={ViteLogo}
className="relative m-auto"
width={301}
height={60}
alt="Vite"
/>
<div className="relative z-20 mt-auto">
<blockquote className="space-y-2">
<p className="text-lg">
&ldquo;This template has saved me countless hours of work and
helped me deliver stunning designs to my clients faster than ever
before.&rdquo;
</p>
<footer className="text-sm">John Doe</footer>
</blockquote>
</div>
</div>
<div className="lg:p-8">
<div className="mx-auto flex w-full flex-col justify-center space-y-2 sm:w-[350px]">
<div className="flex flex-col space-y-2 text-left">
<h1 className="text-2xl font-semibold tracking-tight">Login</h1>
<p className="text-sm text-muted-foreground">
Enter your email and password below <br />
to log into your account
</p>
</div>
<UserAuthForm />
<p className="px-8 text-center text-sm text-muted-foreground">
By clicking login, you agree to our{" "}
<a
href="/terms"
className="underline underline-offset-4 hover:text-primary">
Terms of Service
</a>{" "}
and{" "}
<a
href="/privacy"
className="underline underline-offset-4 hover:text-primary">
Privacy Policy
</a>
.
</p>
</div>
</div>
</div>
);
}
@@ -0,0 +1,144 @@
import { HTMLAttributes, useState } from "react";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { IconBrandFacebook, IconBrandGithub } from "@tabler/icons-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { PasswordInput } from "@/components/password-input";
type SignUpFormProps = HTMLAttributes<HTMLDivElement>;
const formSchema = z
.object({
email: z
.string()
.min(1, { message: "Please enter your email" })
.email({ message: "Invalid email address" }),
password: z
.string()
.min(1, {
message: "Please enter your password"
})
.min(7, {
message: "Password must be at least 7 characters long"
}),
confirmPassword: z.string()
})
.refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match.",
path: ["confirmPassword"]
});
export function SignUpForm({ className, ...props }: SignUpFormProps) {
const [isLoading, setIsLoading] = useState(false);
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
email: "",
password: "",
confirmPassword: ""
}
});
function onSubmit(data: z.infer<typeof formSchema>) {
setIsLoading(true);
// eslint-disable-next-line no-console
console.log(data);
setTimeout(() => {
setIsLoading(false);
}, 3000);
}
return (
<div className={cn("grid gap-6", className)} {...props}>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className="grid gap-2">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem className="space-y-1">
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="name@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem className="space-y-1">
<FormLabel>Password</FormLabel>
<FormControl>
<PasswordInput placeholder="********" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="confirmPassword"
render={({ field }) => (
<FormItem className="space-y-1">
<FormLabel>Confirm Password</FormLabel>
<FormControl>
<PasswordInput placeholder="********" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button className="mt-2" disabled={isLoading}>
Create Account
</Button>
<div className="relative my-2">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">
Or continue with
</span>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
className="w-full"
type="button"
disabled={isLoading}>
<IconBrandGithub className="h-4 w-4" /> GitHub
</Button>
<Button
variant="outline"
className="w-full"
type="button"
disabled={isLoading}>
<IconBrandFacebook className="h-4 w-4" /> Facebook
</Button>
</div>
</div>
</form>
</Form>
</div>
);
}
+43
View File
@@ -0,0 +1,43 @@
import { Link } from "@tanstack/react-router";
import { Card } from "@/components/ui/card";
import AuthLayout from "../auth-layout";
import { SignUpForm } from "./components/sign-up-form";
export default function SignUp() {
return (
<AuthLayout>
<Card className="p-6">
<div className="mb-2 flex flex-col space-y-2 text-left">
<h1 className="text-lg font-semibold tracking-tight">
Create an account
</h1>
<p className="text-sm text-muted-foreground">
Enter your email and password to create an account. <br />
Already have an account?{" "}
<Link
to="/sign-in"
className="underline underline-offset-4 hover:text-primary">
Sign In
</Link>
</p>
</div>
<SignUpForm />
<p className="mt-4 px-8 text-center text-sm text-muted-foreground">
By creating an account, you agree to our{" "}
<a
href="/terms"
className="underline underline-offset-4 hover:text-primary">
Terms of Service
</a>{" "}
and{" "}
<a
href="/privacy"
className="underline underline-offset-4 hover:text-primary">
Privacy Policy
</a>
.
</p>
</Card>
</AuthLayout>
);
}
@@ -0,0 +1,135 @@
import { useEffect, useState } from "react";
import { IconCheck, IconX } from "@tabler/icons-react";
import { toast } from "@/hooks/use-toast";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList
} from "@/components/ui/command";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle
} from "@/components/ui/dialog";
import { ChatUser } from "../data/chat-types";
type User = Omit<ChatUser, "messages">;
type Props = {
users: User[];
open: boolean;
onOpenChange: (open: boolean) => void;
};
export function NewChat({ users, onOpenChange, open }: Props) {
const [selectedUsers, setSelectedUsers] = useState<User[]>([]);
const handleSelectUser = (user: User) => {
if (!selectedUsers.find((u) => u.id === user.id)) {
setSelectedUsers([...selectedUsers, user]);
} else {
handleRemoveUser(user.id);
}
};
const handleRemoveUser = (userId: string) => {
setSelectedUsers(selectedUsers.filter((user) => user.id !== userId));
};
useEffect(() => {
if (!open) {
setSelectedUsers([]);
}
}, [open]);
const onSubmit = () => {
toast({
title: "You submitted the following values:",
description: (
<pre className="mt-2 w-[340px] rounded-md bg-slate-950 p-4">
<code className="text-white">
{JSON.stringify(selectedUsers, null, 2)}
</code>
</pre>
)
});
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle>New message</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm text-zinc-400">To:</span>
{selectedUsers.map((user) => (
<Badge key={user.id} variant="default">
{user.fullName}
<button
className="ml-1 rounded-full outline-none ring-offset-background focus:ring-2 focus:ring-ring focus:ring-offset-2"
onKeyDown={(e) => {
if (e.key === "Enter") {
handleRemoveUser(user.id);
}
}}
onClick={() => handleRemoveUser(user.id)}>
<IconX className="h-3 w-3 text-muted-foreground hover:text-foreground" />
</button>
</Badge>
))}
</div>
<Command className="rounded-lg border">
<CommandInput
placeholder="Search people..."
className="text-foreground"
/>
<CommandList>
<CommandEmpty>No people found.</CommandEmpty>
<CommandGroup>
{users.map((user) => (
<CommandItem
key={user.id}
onSelect={() => handleSelectUser(user)}
className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<img
src={user.profile || "/placeholder.svg"}
alt={user.fullName}
className="h-8 w-8 rounded-full"
/>
<div className="flex flex-col">
<span className="text-sm font-medium">
{user.fullName}
</span>
<span className="text-xs text-zinc-400">
{user.username}
</span>
</div>
</div>
{selectedUsers.find((u) => u.id === user.id) && (
<IconCheck className="h-4 w-4" />
)}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
<Button
variant={"default"}
onClick={onSubmit}
disabled={selectedUsers.length === 0}>
Chat
</Button>
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,4 @@
import { conversations } from "./convo.json";
export type ChatUser = (typeof conversations)[number];
export type Convo = ChatUser["messages"][number];
+309
View File
@@ -0,0 +1,309 @@
{
"conversations": [
{
"id": "conv1",
"profile": "https://randomuser.me/api/portraits/men/32.jpg",
"username": "alex_dev",
"fullName": "Alex John",
"title": "Senior Backend Dev",
"messages": [
{
"sender": "You",
"message": "See you later, Alex!",
"timestamp": "2024-08-24T11:15:15"
},
{
"sender": "Alex",
"message": "Alright, talk to you later!",
"timestamp": "2024-08-24T11:11:30"
},
{
"sender": "You",
"message": "For sure. Anyway, I should get back to reviewing the project.",
"timestamp": "2024-08-23T09:26:50"
},
{
"sender": "Alex",
"message": "Yeah, let me know what you think.",
"timestamp": "2024-08-23T09:25:15"
},
{
"sender": "You",
"message": "Oh, nice! I've been waiting for that. I'll check it out later.",
"timestamp": "2024-08-23T09:24:30"
},
{
"sender": "Alex",
"message": "They've added a dark mode option! It looks really sleek.",
"timestamp": "2024-08-23T09:23:10"
},
{
"sender": "You",
"message": "No, not yet. What's new?",
"timestamp": "2024-08-23T09:22:00"
},
{
"sender": "Alex",
"message": "By the way, have you seen the new feature update?",
"timestamp": "2024-08-23T09:21:05"
},
{
"sender": "You",
"message": "Will do! Thanks, Alex.",
"timestamp": "2024-08-23T09:20:10"
},
{
"sender": "Alex",
"message": "Great! Let me know if you need any help.",
"timestamp": "2024-08-23T09:19:20"
},
{
"sender": "You",
"message": "Almost done. Just need to review a few things.",
"timestamp": "2024-08-23T09:18:45"
},
{
"sender": "Alex",
"message": "I'm good, thanks! Did you finish the project?",
"timestamp": "2024-08-23T09:17:10"
},
{
"sender": "You",
"message": "Hey Alex, I'm doing well! How about you?",
"timestamp": "2024-08-23T09:16:30"
},
{
"sender": "Alex",
"message": "Hey Bob, how are you doing?",
"timestamp": "2024-08-23T09:15:00"
}
]
},
{
"id": "conv2",
"profile": "https://randomuser.me/api/portraits/women/45.jpg",
"username": "taylor.codes",
"fullName": "Taylor Grande",
"title": "Tech Lead",
"messages": [
{
"sender": "Taylor",
"message": "Yeah, it's really well-explained. You should give it a try.",
"timestamp": "2024-08-23T10:35:00"
},
{
"sender": "You",
"message": "Not yet, is it good?",
"timestamp": "2024-08-23T10:32:00"
},
{
"sender": "Taylor",
"message": "Hey, did you check out that new tutorial?",
"timestamp": "2024-08-23T10:30:00"
}
]
},
{
"id": "conv3",
"profile": "https://randomuser.me/api/portraits/men/54.jpg",
"username": "john_stack",
"fullName": "John Doe",
"title": "QA",
"messages": [
{
"sender": "You",
"message": "Yep, see ya. 👋🏼",
"timestamp": "2024-08-22T18:59:00"
},
{
"sender": "John",
"message": "Great, see you then!",
"timestamp": "2024-08-22T18:55:00"
},
{
"sender": "You",
"message": "Yes, same time as usual. I'll send the invite shortly.",
"timestamp": "2024-08-22T18:50:00"
},
{
"sender": "John",
"message": "Are we still on for the meeting tomorrow?",
"timestamp": "2024-08-22T18:45:00"
}
]
},
{
"id": "conv4",
"profile": "https://randomuser.me/api/portraits/women/29.jpg",
"username": "megan_frontend",
"fullName": "Megan Flux",
"title": "Jr Developer",
"messages": [
{
"sender": "You",
"message": "Sure ✌🏼",
"timestamp": "2024-08-23T11:30:00"
},
{
"sender": "Megan",
"message": "Thanks, appreciate it!",
"timestamp": "2024-08-23T11:30:00"
},
{
"sender": "You",
"message": "Sure thing! I'll take a look in the next hour.",
"timestamp": "2024-08-23T11:25:00"
},
{
"sender": "Megan",
"message": "Hey! Do you have time to review my PR today?",
"timestamp": "2024-08-23T11:20:00"
}
]
},
{
"id": "conv5",
"profile": "https://randomuser.me/api/portraits/men/72.jpg",
"username": "dev_david",
"fullName": "David Brown",
"title": "Senior UI/UX Designer",
"messages": [
{
"sender": "You",
"message": "Great, I'll review them now!",
"timestamp": "2024-08-23T12:00:00"
},
{
"sender": "David",
"message": "Just sent you the files. Let me know if you need any changes.",
"timestamp": "2024-08-23T11:58:00"
},
{
"sender": "David",
"message": "I finished the design for the dashboard. Thoughts?",
"timestamp": "2024-08-23T11:55:00"
}
]
},
{
"id": "conv6",
"profile": "https://randomuser.me/api/portraits/women/68.jpg",
"username": "julia.design",
"fullName": "Julia Carter",
"title": "Product Designer",
"messages": [
{
"sender": "Julia",
"message": "Same here! It's coming together nicely.",
"timestamp": "2024-08-22T14:10:00"
},
{
"sender": "You",
"message": "I'm really excited to see the final product!",
"timestamp": "2024-08-22T14:15:00"
},
{
"sender": "You",
"message": "How's the project looking on your end?",
"timestamp": "2024-08-22T14:05:00"
}
]
},
{
"id": "conv7",
"profile": "https://randomuser.me/api/portraits/men/24.jpg",
"username": "brad_dev",
"fullName": "Brad Wilson",
"title": "CEO",
"messages": [
{
"sender": "Brad",
"message": "Got it! Thanks for the update.",
"timestamp": "2024-08-23T15:45:00"
},
{
"sender": "You",
"message": "The release has been delayed to next week.",
"timestamp": "2024-08-23T15:40:00"
},
{
"sender": "Brad",
"message": "Hey, any news on the release?",
"timestamp": "2024-08-23T15:35:00"
}
]
},
{
"id": "conv8",
"profile": "https://randomuser.me/api/portraits/women/34.jpg",
"username": "katie_ui",
"fullName": "Katie Lee",
"title": "QA",
"messages": [
{
"sender": "Katie",
"message": "I'll join the call in a few minutes.",
"timestamp": "2024-08-23T09:50:00"
},
{
"sender": "You",
"message": "Perfect! We'll start as soon as you're in.",
"timestamp": "2024-08-23T09:48:00"
},
{
"sender": "Katie",
"message": "Is the meeting still on?",
"timestamp": "2024-08-23T09:45:00"
}
]
},
{
"id": "conv9",
"profile": "https://randomuser.me/api/portraits/men/67.jpg",
"username": "matt_fullstack",
"fullName": "Matt Green",
"title": "Full-stack Dev",
"messages": [
{
"sender": "Matt",
"message": "Sure thing, I'll send over the updates shortly.",
"timestamp": "2024-08-23T10:25:00"
},
{
"sender": "You",
"message": "Could you update the backend as well?",
"timestamp": "2024-08-23T10:23:00"
},
{
"sender": "Matt",
"message": "The frontend updates are done. How does it look?",
"timestamp": "2024-08-23T10:20:00"
}
]
},
{
"id": "conv10",
"profile": "https://randomuser.me/api/portraits/women/56.jpg",
"username": "sophie_dev",
"fullName": "Sophie Alex",
"title": "Jr. Frontend Dev",
"messages": [
{
"sender": "You",
"message": "Thanks! I'll review your code and get back to you.",
"timestamp": "2024-08-23T16:10:00"
},
{
"sender": "Sophie",
"message": "Let me know if you need anything else.",
"timestamp": "2024-08-23T16:05:00"
},
{
"sender": "Sophie",
"message": "The feature is implemented. Can you review it?",
"timestamp": "2024-08-23T16:00:00"
}
]
}
]
}
+331
View File
@@ -0,0 +1,331 @@
import { useState } from "react";
import { Fragment } from "react/jsx-runtime";
import { format } from "date-fns";
import {
IconArrowLeft,
IconDotsVertical,
IconEdit,
IconMessages,
IconPaperclip,
IconPhone,
IconPhotoPlus,
IconPlus,
IconSearch,
IconSend,
IconVideo
} from "@tabler/icons-react";
import { cn } from "@/lib/utils";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import { Header } from "@/components/layout/header";
import { Main } from "@/components/layout/main";
import { ProfileDropdown } from "@/components/profile-dropdown";
import { Search } from "@/components/search";
import { ThemeSwitch } from "@/components/theme-switch";
import { NewChat } from "./components/new-chat";
import { type ChatUser, type Convo } from "./data/chat-types";
// Fake Data
import { conversations } from "./data/convo.json";
export default function Chats() {
const [search, setSearch] = useState("");
const [selectedUser, setSelectedUser] = useState<ChatUser | null>(null);
const [mobileSelectedUser, setMobileSelectedUser] = useState<ChatUser | null>(
null
);
const [createConversationDialogOpened, setCreateConversationDialog] =
useState(false);
// Filtered data based on the search query
const filteredChatList = conversations.filter(({ fullName }) =>
fullName.toLowerCase().includes(search.trim().toLowerCase())
);
const currentMessage = selectedUser?.messages.reduce(
(acc: Record<string, Convo[]>, obj) => {
const key = format(obj.timestamp, "d MMM, yyyy");
// Create an array for the category if it doesn't exist
if (!acc[key]) {
acc[key] = [];
}
// Push the current object to the array
acc[key].push(obj);
return acc;
},
{}
);
const users = conversations.map(({ messages, ...user }) => user);
return (
<>
{/* ===== Top Heading ===== */}
<Header>
<Search />
<div className="ml-auto flex items-center space-x-4">
<ThemeSwitch />
<ProfileDropdown />
</div>
</Header>
<Main fixed>
<section className="flex h-full gap-6">
{/* Left Side */}
<div className="flex w-full flex-col gap-2 sm:w-56 lg:w-72 2xl:w-80">
<div className="sticky top-0 z-10 -mx-4 bg-background px-4 pb-3 shadow-md sm:static sm:z-auto sm:mx-0 sm:p-0 sm:shadow-none">
<div className="flex items-center justify-between py-2">
<div className="flex gap-2">
<h1 className="text-2xl font-bold">Inbox</h1>
<IconMessages size={20} />
</div>
<Button
size="icon"
variant="ghost"
onClick={() => setCreateConversationDialog(true)}
className="rounded-lg">
<IconEdit size={24} className="stroke-muted-foreground" />
</Button>
</div>
<label className="flex h-12 w-full items-center space-x-0 rounded-md border border-input pl-2 focus-within:outline-none focus-within:ring-1 focus-within:ring-ring">
<IconSearch size={15} className="mr-2 stroke-slate-500" />
<span className="sr-only">Search</span>
<input
type="text"
className="w-full flex-1 bg-inherit text-sm focus-visible:outline-none"
placeholder="Search chat..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</label>
</div>
<ScrollArea className="-mx-3 h-full p-3">
{filteredChatList.map((chatUsr) => {
const { id, profile, username, messages, fullName } = chatUsr;
const lastConvo = messages[0];
const lastMsg =
lastConvo.sender === "You"
? `You: ${lastConvo.message}`
: lastConvo.message;
return (
<Fragment key={id}>
<button
type="button"
className={cn(
`-mx-1 flex w-full rounded-md px-2 py-2 text-left text-sm hover:bg-secondary/75`,
selectedUser?.id === id && "sm:bg-muted"
)}
onClick={() => {
setSelectedUser(chatUsr);
setMobileSelectedUser(chatUsr);
}}>
<div className="flex gap-2">
<Avatar>
<AvatarImage src={profile} alt={username} />
<AvatarFallback>{username}</AvatarFallback>
</Avatar>
<div>
<span className="col-start-2 row-span-2 font-medium">
{fullName}
</span>
<span className="col-start-2 row-span-2 row-start-2 line-clamp-2 text-ellipsis text-muted-foreground">
{lastMsg}
</span>
</div>
</div>
</button>
<Separator className="my-1" />
</Fragment>
);
})}
</ScrollArea>
</div>
{/* Right Side */}
{selectedUser ? (
<div
className={cn(
"absolute inset-0 left-full z-50 hidden w-full flex-1 flex-col rounded-md border bg-primary-foreground shadow-sm transition-all duration-200 sm:static sm:z-auto sm:flex",
mobileSelectedUser && "left-0 flex"
)}>
{/* Top Part */}
<div className="mb-1 flex flex-none justify-between rounded-t-md bg-secondary p-4 shadow-lg">
{/* Left */}
<div className="flex gap-3">
<Button
size="icon"
variant="ghost"
className="-ml-2 h-full sm:hidden"
onClick={() => setMobileSelectedUser(null)}>
<IconArrowLeft />
</Button>
<div className="flex items-center gap-2 lg:gap-4">
<Avatar className="size-9 lg:size-11">
<AvatarImage
src={selectedUser.profile}
alt={selectedUser.username}
/>
<AvatarFallback>{selectedUser.username}</AvatarFallback>
</Avatar>
<div>
<span className="col-start-2 row-span-2 text-sm font-medium lg:text-base">
{selectedUser.fullName}
</span>
<span className="col-start-2 row-span-2 row-start-2 line-clamp-1 block max-w-32 text-ellipsis text-nowrap text-xs text-muted-foreground lg:max-w-none lg:text-sm">
{selectedUser.title}
</span>
</div>
</div>
</div>
{/* Right */}
<div className="-mr-1 flex items-center gap-1 lg:gap-2">
<Button
size="icon"
variant="ghost"
className="hidden size-8 rounded-full sm:inline-flex lg:size-10">
<IconVideo size={22} className="stroke-muted-foreground" />
</Button>
<Button
size="icon"
variant="ghost"
className="hidden size-8 rounded-full sm:inline-flex lg:size-10">
<IconPhone size={22} className="stroke-muted-foreground" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-10 rounded-md sm:h-8 sm:w-4 lg:h-10 lg:w-6">
<IconDotsVertical className="stroke-muted-foreground sm:size-5" />
</Button>
</div>
</div>
{/* Conversation */}
<div className="flex flex-1 flex-col gap-2 rounded-md px-4 pb-4 pt-0">
<div className="flex size-full flex-1">
<div className="chat-text-container relative -mr-4 flex flex-1 flex-col overflow-y-hidden">
<div className="chat-flex flex h-40 w-full flex-grow flex-col-reverse justify-start gap-4 overflow-y-auto py-2 pb-4 pr-4">
{currentMessage &&
Object.keys(currentMessage).map((key) => (
<Fragment key={key}>
{currentMessage[key].map((msg, index) => (
<div
key={`${msg.sender}-${msg.timestamp}-${index}`}
className={cn(
"chat-box max-w-72 break-words px-3 py-2 shadow-lg",
msg.sender === "You"
? "self-end rounded-[16px_16px_0_16px] bg-primary/85 text-primary-foreground/75"
: "self-start rounded-[16px_16px_16px_0] bg-secondary"
)}>
{msg.message}{" "}
<span
className={cn(
"mt-1 block text-xs font-light italic text-muted-foreground",
msg.sender === "You" && "text-right"
)}>
{format(msg.timestamp, "h:mm a")}
</span>
</div>
))}
<div className="text-center text-xs">{key}</div>
</Fragment>
))}
</div>
</div>
</div>
<form className="flex w-full flex-none gap-2">
<div className="flex flex-1 items-center gap-2 rounded-md border border-input px-2 py-1 focus-within:outline-none focus-within:ring-1 focus-within:ring-ring lg:gap-4">
<div className="space-x-1">
<Button
size="icon"
type="button"
variant="ghost"
className="h-8 rounded-md">
<IconPlus
size={20}
className="stroke-muted-foreground"
/>
</Button>
<Button
size="icon"
type="button"
variant="ghost"
className="hidden h-8 rounded-md lg:inline-flex">
<IconPhotoPlus
size={20}
className="stroke-muted-foreground"
/>
</Button>
<Button
size="icon"
type="button"
variant="ghost"
className="hidden h-8 rounded-md lg:inline-flex">
<IconPaperclip
size={20}
className="stroke-muted-foreground"
/>
</Button>
</div>
<label className="flex-1">
<span className="sr-only">Chat Text Box</span>
<input
type="text"
placeholder="Type your messages..."
className="h-8 w-full bg-inherit focus-visible:outline-none"
/>
</label>
<Button
variant="ghost"
size="icon"
className="hidden sm:inline-flex">
<IconSend size={20} />
</Button>
</div>
<Button className="h-full sm:hidden">
<IconSend size={18} /> Send
</Button>
</form>
</div>
</div>
) : (
<div
className={cn(
"absolute inset-0 left-full z-50 hidden w-full flex-1 flex-col justify-center rounded-md border bg-primary-foreground shadow-sm transition-all duration-200 sm:static sm:z-auto sm:flex"
)}>
<div className="flex flex-col items-center space-y-6">
<div className="flex h-16 w-16 items-center justify-center rounded-full border-2 border-white">
<IconMessages className="h-8 w-8" />
</div>
<div className="space-y-2 text-center">
<h1 className="text-xl font-semibold">Your messages</h1>
<p className="text-sm text-gray-400">
Send a message to start a chat.
</p>
</div>
<Button
className="bg-blue-500 px-6 text-white hover:bg-blue-600"
onClick={() => setCreateConversationDialog(true)}>
Send message
</Button>
</div>
</div>
)}
</section>
<NewChat
users={users}
onOpenChange={setCreateConversationDialog}
open={createConversationDialogOpened}
/>
</Main>
</>
);
}
@@ -0,0 +1,81 @@
import { Bar, BarChart, ResponsiveContainer, XAxis, YAxis } from "recharts";
const data = [
{
name: "Jan",
total: Math.floor(Math.random() * 5000) + 1000
},
{
name: "Feb",
total: Math.floor(Math.random() * 5000) + 1000
},
{
name: "Mar",
total: Math.floor(Math.random() * 5000) + 1000
},
{
name: "Apr",
total: Math.floor(Math.random() * 5000) + 1000
},
{
name: "May",
total: Math.floor(Math.random() * 5000) + 1000
},
{
name: "Jun",
total: Math.floor(Math.random() * 5000) + 1000
},
{
name: "Jul",
total: Math.floor(Math.random() * 5000) + 1000
},
{
name: "Aug",
total: Math.floor(Math.random() * 5000) + 1000
},
{
name: "Sep",
total: Math.floor(Math.random() * 5000) + 1000
},
{
name: "Oct",
total: Math.floor(Math.random() * 5000) + 1000
},
{
name: "Nov",
total: Math.floor(Math.random() * 5000) + 1000
},
{
name: "Dec",
total: Math.floor(Math.random() * 5000) + 1000
}
];
export function Overview() {
return (
<ResponsiveContainer width="100%" height={350}>
<BarChart data={data}>
<XAxis
dataKey="name"
stroke="#888888"
fontSize={12}
tickLine={false}
axisLine={false}
/>
<YAxis
stroke="#888888"
fontSize={12}
tickLine={false}
axisLine={false}
tickFormatter={(value) => `$${value}`}
/>
<Bar
dataKey="total"
fill="currentColor"
radius={[4, 4, 0, 0]}
className="fill-primary"
/>
</BarChart>
</ResponsiveContainer>
);
}
@@ -0,0 +1,83 @@
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
export function RecentSales() {
return (
<div className="space-y-8">
<div className="flex items-center gap-4">
<Avatar className="h-9 w-9">
<AvatarImage src="/avatars/01.png" alt="Avatar" />
<AvatarFallback>OM</AvatarFallback>
</Avatar>
<div className="flex flex-1 flex-wrap items-center justify-between">
<div className="space-y-1">
<p className="text-sm font-medium leading-none">Olivia Martin</p>
<p className="text-sm text-muted-foreground">
olivia.martin@email.com
</p>
</div>
<div className="font-medium">+$1,999.00</div>
</div>
</div>
<div className="flex items-center gap-4">
<Avatar className="flex h-9 w-9 items-center justify-center space-y-0 border">
<AvatarImage src="/avatars/02.png" alt="Avatar" />
<AvatarFallback>JL</AvatarFallback>
</Avatar>
<div className="flex flex-1 flex-wrap items-center justify-between">
<div className="space-y-1">
<p className="text-sm font-medium leading-none">Jackson Lee</p>
<p className="text-sm text-muted-foreground">
jackson.lee@email.com
</p>
</div>
<div className="font-medium">+$39.00</div>
</div>
</div>
<div className="flex items-center gap-4">
<Avatar className="h-9 w-9">
<AvatarImage src="/avatars/03.png" alt="Avatar" />
<AvatarFallback>IN</AvatarFallback>
</Avatar>
<div className="flex flex-1 flex-wrap items-center justify-between">
<div className="space-y-1">
<p className="text-sm font-medium leading-none">Isabella Nguyen</p>
<p className="text-sm text-muted-foreground">
isabella.nguyen@email.com
</p>
</div>
<div className="font-medium">+$299.00</div>
</div>
</div>
<div className="flex items-center gap-4">
<Avatar className="h-9 w-9">
<AvatarImage src="/avatars/04.png" alt="Avatar" />
<AvatarFallback>WK</AvatarFallback>
</Avatar>
<div className="flex flex-1 flex-wrap items-center justify-between">
<div className="space-y-1">
<p className="text-sm font-medium leading-none">William Kim</p>
<p className="text-sm text-muted-foreground">will@email.com</p>
</div>
<div className="font-medium">+$99.00</div>
</div>
</div>
<div className="flex items-center gap-4">
<Avatar className="h-9 w-9">
<AvatarImage src="/avatars/05.png" alt="Avatar" />
<AvatarFallback>SD</AvatarFallback>
</Avatar>
<div className="flex flex-1 flex-wrap items-center justify-between">
<div className="space-y-1">
<p className="text-sm font-medium leading-none">Sofia Davis</p>
<p className="text-sm text-muted-foreground">
sofia.davis@email.com
</p>
</div>
<div className="font-medium">+$39.00</div>
</div>
</div>
</div>
);
}
+211
View File
@@ -0,0 +1,211 @@
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Header } from "@/components/layout/header";
import { Main } from "@/components/layout/main";
import { TopNav } from "@/components/layout/top-nav";
import { ProfileDropdown } from "@/components/profile-dropdown";
import { Search } from "@/components/search";
import { ThemeSwitch } from "@/components/theme-switch";
import { Overview } from "./components/overview";
import { RecentSales } from "./components/recent-sales";
export default function Dashboard() {
return (
<>
{/* ===== Top Heading ===== */}
<Header>
<TopNav links={topNav} />
<div className="ml-auto flex items-center space-x-4">
<Search />
<ThemeSwitch />
<ProfileDropdown />
</div>
</Header>
{/* ===== Main ===== */}
<Main>
<div className="mb-2 flex items-center justify-between space-y-2">
<h1 className="text-2xl font-bold tracking-tight">Dashboard</h1>
<div className="flex items-center space-x-2">
<Button>Download</Button>
</div>
</div>
<Tabs
orientation="vertical"
defaultValue="overview"
className="space-y-4">
<div className="w-full overflow-x-auto pb-2">
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="analytics" disabled>
Analytics
</TabsTrigger>
<TabsTrigger value="reports" disabled>
Reports
</TabsTrigger>
<TabsTrigger value="notifications" disabled>
Notifications
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="overview" className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Total Revenue
</CardTitle>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
className="h-4 w-4 text-muted-foreground">
<path d="M12 2v20M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" />
</svg>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">$45,231.89</div>
<p className="text-xs text-muted-foreground">
+20.1% from last month
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Subscriptions
</CardTitle>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
className="h-4 w-4 text-muted-foreground">
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M22 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">+2350</div>
<p className="text-xs text-muted-foreground">
+180.1% from last month
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Sales</CardTitle>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
className="h-4 w-4 text-muted-foreground">
<rect width="20" height="14" x="2" y="5" rx="2" />
<path d="M2 10h20" />
</svg>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">+12,234</div>
<p className="text-xs text-muted-foreground">
+19% from last month
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Active Now
</CardTitle>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
className="h-4 w-4 text-muted-foreground">
<path d="M22 12h-4l-3 9L9 3l-3 9H2" />
</svg>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">+573</div>
<p className="text-xs text-muted-foreground">
+201 since last hour
</p>
</CardContent>
</Card>
</div>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-7">
<Card className="col-span-1 lg:col-span-4">
<CardHeader>
<CardTitle>Overview</CardTitle>
</CardHeader>
<CardContent className="pl-2">
<Overview />
</CardContent>
</Card>
<Card className="col-span-1 lg:col-span-3">
<CardHeader>
<CardTitle>Recent Sales</CardTitle>
<CardDescription>
You made 265 sales this month.
</CardDescription>
</CardHeader>
<CardContent>
<RecentSales />
</CardContent>
</Card>
</div>
</TabsContent>
</Tabs>
</Main>
</>
);
}
const topNav = [
{
title: "Overview",
href: "dashboard/overview",
isActive: true,
disabled: false
},
{
title: "Customers",
href: "dashboard/customers",
isActive: false,
disabled: true
},
{
title: "Products",
href: "dashboard/products",
isActive: false,
disabled: true
},
{
title: "Settings",
href: "dashboard/settings",
isActive: false,
disabled: true
}
];
+25
View File
@@ -0,0 +1,25 @@
import { useNavigate, useRouter } from "@tanstack/react-router";
import { Button } from "@/components/ui/button";
export default function ForbiddenError() {
const navigate = useNavigate();
const { history } = useRouter();
return (
<div className="h-svh">
<div className="m-auto flex h-full w-full flex-col items-center justify-center gap-2">
<h1 className="text-[7rem] font-bold leading-tight">403</h1>
<span className="font-medium">Access Forbidden</span>
<p className="text-center text-muted-foreground">
You don't have necessary permission <br />
to view this resource.
</p>
<div className="mt-6 flex gap-4">
<Button variant="outline" onClick={() => history.go(-1)}>
Go Back
</Button>
<Button onClick={() => navigate({ to: "/" })}>Back to Home</Button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,36 @@
import { useNavigate, useRouter } from "@tanstack/react-router";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
interface GeneralErrorProps extends React.HTMLAttributes<HTMLDivElement> {
minimal?: boolean;
}
export default function GeneralError({
className,
minimal = false
}: GeneralErrorProps) {
const navigate = useNavigate();
const { history } = useRouter();
return (
<div className={cn("h-svh w-full", className)}>
<div className="m-auto flex h-full w-full flex-col items-center justify-center gap-2">
{!minimal && (
<h1 className="text-[7rem] font-bold leading-tight">500</h1>
)}
<span className="font-medium">Oops! Something went wrong {`:')`}</span>
<p className="text-center text-muted-foreground">
We apologize for the inconvenience. <br /> Please try again later.
</p>
{!minimal && (
<div className="mt-6 flex gap-4">
<Button variant="outline" onClick={() => history.go(-1)}>
Go Back
</Button>
<Button onClick={() => navigate({ to: "/" })}>Back to Home</Button>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,19 @@
import { Button } from "@/components/ui/button";
export default function MaintenanceError() {
return (
<div className="h-svh">
<div className="m-auto flex h-full w-full flex-col items-center justify-center gap-2">
<h1 className="text-[7rem] font-bold leading-tight">503</h1>
<span className="font-medium">Website is under maintenance!</span>
<p className="text-center text-muted-foreground">
The site is not available at the moment. <br />
We'll be back online shortly.
</p>
<div className="mt-6 flex gap-4">
<Button variant="outline">Learn more</Button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,25 @@
import { useNavigate, useRouter } from "@tanstack/react-router";
import { Button } from "@/components/ui/button";
export default function NotFoundError() {
const navigate = useNavigate();
const { history } = useRouter();
return (
<div className="h-svh">
<div className="m-auto flex h-full w-full flex-col items-center justify-center gap-2">
<h1 className="text-[7rem] font-bold leading-tight">404</h1>
<span className="font-medium">Oops! Page Not Found!</span>
<p className="text-center text-muted-foreground">
It seems like the page you're looking for <br />
does not exist or might have been removed.
</p>
<div className="mt-6 flex gap-4">
<Button variant="outline" onClick={() => history.go(-1)}>
Go Back
</Button>
<Button onClick={() => navigate({ to: "/" })}>Back to Home</Button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,25 @@
import { useNavigate, useRouter } from "@tanstack/react-router";
import { Button } from "@/components/ui/button";
export default function UnauthorisedError() {
const navigate = useNavigate();
const { history } = useRouter();
return (
<div className="h-svh">
<div className="m-auto flex h-full w-full flex-col items-center justify-center gap-2">
<h1 className="text-[7rem] font-bold leading-tight">401</h1>
<span className="font-medium">Unauthorized Access</span>
<p className="text-center text-muted-foreground">
Please log in with the appropriate credentials <br /> to access this
resource.
</p>
<div className="mt-6 flex gap-4">
<Button variant="outline" onClick={() => history.go(-1)}>
Go Back
</Button>
<Button onClick={() => navigate({ to: "/" })}>Back to Home</Button>
</div>
</div>
</div>
);
}
+3
View File
@@ -0,0 +1,3 @@
export default function Nba() {
return <h1>NBA Testing</h1>;
}
@@ -0,0 +1,214 @@
import { z } from "zod";
import { format } from "date-fns";
import { useForm } from "react-hook-form";
import { CalendarIcon, CaretSortIcon, CheckIcon } from "@radix-ui/react-icons";
import { zodResolver } from "@hookform/resolvers/zod";
import { cn } from "@/lib/utils";
import { toast } from "@/hooks/use-toast";
import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList
} from "@/components/ui/command";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger
} from "@/components/ui/popover";
const languages = [
{ label: "English", value: "en" },
{ label: "French", value: "fr" },
{ label: "German", value: "de" },
{ label: "Spanish", value: "es" },
{ label: "Portuguese", value: "pt" },
{ label: "Russian", value: "ru" },
{ label: "Japanese", value: "ja" },
{ label: "Korean", value: "ko" },
{ label: "Chinese", value: "zh" }
] as const;
const accountFormSchema = z.object({
name: z
.string()
.min(2, {
message: "Name must be at least 2 characters."
})
.max(30, {
message: "Name must not be longer than 30 characters."
}),
dob: z.date({
required_error: "A date of birth is required."
}),
language: z.string({
required_error: "Please select a language."
})
});
type AccountFormValues = z.infer<typeof accountFormSchema>;
// This can come from your database or API.
const defaultValues: Partial<AccountFormValues> = {
name: ""
};
export function AccountForm() {
const form = useForm<AccountFormValues>({
resolver: zodResolver(accountFormSchema),
defaultValues
});
function onSubmit(data: AccountFormValues) {
toast({
title: "You submitted the following values:",
description: (
<pre className="mt-2 w-[340px] rounded-md bg-slate-950 p-4">
<code className="text-white">{JSON.stringify(data, null, 2)}</code>
</pre>
)
});
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="Your name" {...field} />
</FormControl>
<FormDescription>
This is the name that will be displayed on your profile and in
emails.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="dob"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>Date of birth</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant={"outline"}
className={cn(
"w-[240px] pl-3 text-left font-normal",
!field.value && "text-muted-foreground"
)}>
{field.value ? (
format(field.value, "MMM d, yyyy")
) : (
<span>Pick a date</span>
)}
<CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={field.value}
onSelect={field.onChange}
disabled={(date: Date) =>
date > new Date() || date < new Date("1900-01-01")
}
/>
</PopoverContent>
</Popover>
<FormDescription>
Your date of birth is used to calculate your age.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="language"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>Language</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
role="combobox"
className={cn(
"w-[200px] justify-between",
!field.value && "text-muted-foreground"
)}>
{field.value
? languages.find(
(language) => language.value === field.value
)?.label
: "Select language"}
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0">
<Command>
<CommandInput placeholder="Search language..." />
<CommandEmpty>No language found.</CommandEmpty>
<CommandGroup>
<CommandList>
{languages.map((language) => (
<CommandItem
value={language.label}
key={language.value}
onSelect={() => {
form.setValue("language", language.value);
}}>
<CheckIcon
className={cn(
"mr-2 h-4 w-4",
language.value === field.value
? "opacity-100"
: "opacity-0"
)}
/>
{language.label}
</CommandItem>
))}
</CommandList>
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
<FormDescription>
This is the language that will be used in the dashboard.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">Update account</Button>
</form>
</Form>
);
}
@@ -0,0 +1,13 @@
import ContentSection from "../components/content-section";
import { AccountForm } from "./account-form";
export default function SettingsAccount() {
return (
<ContentSection
title="Account"
desc="Update your account settings. Set your preferred language and
timezone.">
<AccountForm />
</ContentSection>
);
}
@@ -0,0 +1,171 @@
import { z } from "zod";
import { useForm } from "react-hook-form";
import { ChevronDownIcon } from "@radix-ui/react-icons";
import { zodResolver } from "@hookform/resolvers/zod";
import { fonts } from "@/config/fonts";
import { cn } from "@/lib/utils";
import { useFont } from "@/context/font-context";
import { useTheme } from "@/context/theme-context";
import { toast } from "@/hooks/use-toast";
import { Button, buttonVariants } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@/components/ui/form";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
const appearanceFormSchema = z.object({
theme: z.enum(["light", "dark"], {
required_error: "Please select a theme."
}),
font: z.enum(fonts, {
invalid_type_error: "Select a font",
required_error: "Please select a font."
})
});
type AppearanceFormValues = z.infer<typeof appearanceFormSchema>;
export function AppearanceForm() {
const { font, setFont } = useFont();
const { theme, setTheme } = useTheme();
// This can come from your database or API.
const defaultValues: Partial<AppearanceFormValues> = {
theme: theme as "light" | "dark",
font
};
const form = useForm<AppearanceFormValues>({
resolver: zodResolver(appearanceFormSchema),
defaultValues
});
function onSubmit(data: AppearanceFormValues) {
if (data.font != font) setFont(data.font);
if (data.theme != theme) setTheme(data.theme);
toast({
title: "You submitted the following values:",
description: (
<pre className="mt-2 w-[340px] rounded-md bg-slate-950 p-4">
<code className="text-white">{JSON.stringify(data, null, 2)}</code>
</pre>
)
});
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
<FormField
control={form.control}
name="font"
render={({ field }) => (
<FormItem>
<FormLabel>Font</FormLabel>
<div className="relative w-max">
<FormControl>
<select
className={cn(
buttonVariants({ variant: "outline" }),
"w-[200px] appearance-none font-normal capitalize"
)}
{...field}>
{fonts.map((font) => (
<option key={font} value={font}>
{font}
</option>
))}
</select>
</FormControl>
<ChevronDownIcon className="absolute right-3 top-2.5 h-4 w-4 opacity-50" />
</div>
<FormDescription className="font-manrope">
Set the font you want to use in the dashboard.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="theme"
render={({ field }) => (
<FormItem className="space-y-1">
<FormLabel>Theme</FormLabel>
<FormDescription>
Select the theme for the dashboard.
</FormDescription>
<FormMessage />
<RadioGroup
onValueChange={field.onChange}
defaultValue={field.value}
className="grid max-w-md grid-cols-2 gap-8 pt-2">
<FormItem>
<FormLabel className="[&:has([data-state=checked])>div]:border-primary">
<FormControl>
<RadioGroupItem value="light" className="sr-only" />
</FormControl>
<div className="items-center rounded-md border-2 border-muted p-1 hover:border-accent">
<div className="space-y-2 rounded-sm bg-[#ecedef] p-2">
<div className="space-y-2 rounded-md bg-white p-2 shadow-sm">
<div className="h-2 w-[80px] rounded-lg bg-[#ecedef]" />
<div className="h-2 w-[100px] rounded-lg bg-[#ecedef]" />
</div>
<div className="flex items-center space-x-2 rounded-md bg-white p-2 shadow-sm">
<div className="h-4 w-4 rounded-full bg-[#ecedef]" />
<div className="h-2 w-[100px] rounded-lg bg-[#ecedef]" />
</div>
<div className="flex items-center space-x-2 rounded-md bg-white p-2 shadow-sm">
<div className="h-4 w-4 rounded-full bg-[#ecedef]" />
<div className="h-2 w-[100px] rounded-lg bg-[#ecedef]" />
</div>
</div>
</div>
<span className="block w-full p-2 text-center font-normal">
Light
</span>
</FormLabel>
</FormItem>
<FormItem>
<FormLabel className="[&:has([data-state=checked])>div]:border-primary">
<FormControl>
<RadioGroupItem value="dark" className="sr-only" />
</FormControl>
<div className="items-center rounded-md border-2 border-muted bg-popover p-1 hover:bg-accent hover:text-accent-foreground">
<div className="space-y-2 rounded-sm bg-slate-950 p-2">
<div className="space-y-2 rounded-md bg-slate-800 p-2 shadow-sm">
<div className="h-2 w-[80px] rounded-lg bg-slate-400" />
<div className="h-2 w-[100px] rounded-lg bg-slate-400" />
</div>
<div className="flex items-center space-x-2 rounded-md bg-slate-800 p-2 shadow-sm">
<div className="h-4 w-4 rounded-full bg-slate-400" />
<div className="h-2 w-[100px] rounded-lg bg-slate-400" />
</div>
<div className="flex items-center space-x-2 rounded-md bg-slate-800 p-2 shadow-sm">
<div className="h-4 w-4 rounded-full bg-slate-400" />
<div className="h-2 w-[100px] rounded-lg bg-slate-400" />
</div>
</div>
</div>
<span className="block w-full p-2 text-center font-normal">
Dark
</span>
</FormLabel>
</FormItem>
</RadioGroup>
</FormItem>
)}
/>
<Button type="submit">Update preferences</Button>
</form>
</Form>
);
}
@@ -0,0 +1,13 @@
import ContentSection from "../components/content-section";
import { AppearanceForm } from "./appearance-form";
export default function SettingsAppearance() {
return (
<ContentSection
title="Appearance"
desc="Customize the appearance of the app. Automatically switch between day
and night themes.">
<AppearanceForm />
</ContentSection>
);
}
@@ -0,0 +1,27 @@
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
interface ContentSectionProps {
title: string;
desc: string;
children: React.JSX.Element;
}
export default function ContentSection({
title,
desc,
children
}: ContentSectionProps) {
return (
<div className="flex flex-1 flex-col">
<div className="flex-none">
<h3 className="text-lg font-medium">{title}</h3>
<p className="text-sm text-muted-foreground">{desc}</p>
</div>
<Separator className="my-4 flex-none" />
<ScrollArea className="faded-bottom -mx-4 flex-1 scroll-smooth px-4 md:pb-16">
<div className="-mx-1 px-1.5 lg:max-w-xl">{children}</div>
</ScrollArea>
</div>
);
}
@@ -0,0 +1,86 @@
import { useState, type JSX } from "react";
import { useLocation, useNavigate } from "@tanstack/react-router";
import { Link } from "@tanstack/react-router";
import { cn } from "@/lib/utils";
import { buttonVariants } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@/components/ui/select";
interface SidebarNavProps extends React.HTMLAttributes<HTMLElement> {
items: {
href: string;
title: string;
icon: JSX.Element;
}[];
}
export default function SidebarNav({
className,
items,
...props
}: SidebarNavProps) {
const { pathname } = useLocation();
const navigate = useNavigate();
const [val, setVal] = useState(pathname ?? "/settings");
const handleSelect = (e: string) => {
setVal(e);
navigate({ to: e });
};
return (
<>
<div className="p-1 md:hidden">
<Select value={val} onValueChange={handleSelect}>
<SelectTrigger className="h-12 sm:w-48">
<SelectValue placeholder="Theme" />
</SelectTrigger>
<SelectContent>
{items.map((item) => (
<SelectItem key={item.href} value={item.href}>
<div className="flex gap-x-4 px-2 py-1">
<span className="scale-125">{item.icon}</span>
<span className="text-md">{item.title}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<ScrollArea
orientation="horizontal"
type="always"
className="hidden w-full min-w-40 bg-background px-1 py-2 md:block">
<nav
className={cn(
"flex space-x-2 py-1 lg:flex-col lg:space-x-0 lg:space-y-1",
className
)}
{...props}>
{items.map((item) => (
<Link
key={item.href}
to={item.href}
className={cn(
buttonVariants({ variant: "ghost" }),
pathname === item.href
? "bg-muted hover:bg-muted"
: "hover:bg-transparent hover:underline",
"justify-start"
)}>
<span className="mr-2">{item.icon}</span>
{item.title}
</Link>
))}
</nav>
</ScrollArea>
</>
);
}
@@ -0,0 +1,128 @@
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { toast } from "@/hooks/use-toast";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@/components/ui/form";
const items = [
{
id: "recents",
label: "Recents"
},
{
id: "home",
label: "Home"
},
{
id: "applications",
label: "Applications"
},
{
id: "desktop",
label: "Desktop"
},
{
id: "downloads",
label: "Downloads"
},
{
id: "documents",
label: "Documents"
}
] as const;
const displayFormSchema = z.object({
items: z.array(z.string()).refine((value) => value.some((item) => item), {
message: "You have to select at least one item."
})
});
type DisplayFormValues = z.infer<typeof displayFormSchema>;
// This can come from your database or API.
const defaultValues: Partial<DisplayFormValues> = {
items: ["recents", "home"]
};
export function DisplayForm() {
const form = useForm<DisplayFormValues>({
resolver: zodResolver(displayFormSchema),
defaultValues
});
function onSubmit(data: DisplayFormValues) {
toast({
title: "You submitted the following values:",
description: (
<pre className="mt-2 w-[340px] rounded-md bg-slate-950 p-4">
<code className="text-white">{JSON.stringify(data, null, 2)}</code>
</pre>
)
});
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
<FormField
control={form.control}
name="items"
render={() => (
<FormItem>
<div className="mb-4">
<FormLabel className="text-base">Sidebar</FormLabel>
<FormDescription>
Select the items you want to display in the sidebar.
</FormDescription>
</div>
{items.map((item) => (
<FormField
key={item.id}
control={form.control}
name="items"
render={({ field }) => {
return (
<FormItem
key={item.id}
className="flex flex-row items-start space-x-3 space-y-0">
<FormControl>
<Checkbox
checked={field.value?.includes(item.id)}
onCheckedChange={(checked) => {
return checked
? field.onChange([...field.value, item.id])
: field.onChange(
field.value?.filter(
(value) => value !== item.id
)
);
}}
/>
</FormControl>
<FormLabel className="font-normal">
{item.label}
</FormLabel>
</FormItem>
);
}}
/>
))}
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">Update display</Button>
</form>
</Form>
);
}
@@ -0,0 +1,12 @@
import ContentSection from "../components/content-section";
import { DisplayForm } from "./display-form";
export default function SettingsDisplay() {
return (
<ContentSection
title="Display"
desc="Turn items on or off to control what's displayed in the app.">
<DisplayForm />
</ContentSection>
);
}
+78
View File
@@ -0,0 +1,78 @@
import { Outlet } from "@tanstack/react-router";
import {
IconBrowserCheck,
IconNotification,
IconPalette,
IconTool,
IconUser
} from "@tabler/icons-react";
import { Separator } from "@/components/ui/separator";
import { Header } from "@/components/layout/header";
import { Main } from "@/components/layout/main";
import { ProfileDropdown } from "@/components/profile-dropdown";
import { Search } from "@/components/search";
import { ThemeSwitch } from "@/components/theme-switch";
import SidebarNav from "./components/sidebar-nav";
export default function Settings() {
return (
<>
{/* ===== Top Heading ===== */}
<Header>
<Search />
<div className="ml-auto flex items-center space-x-4">
<ThemeSwitch />
<ProfileDropdown />
</div>
</Header>
<Main fixed>
<div className="space-y-0.5">
<h1 className="text-2xl font-bold tracking-tight md:text-3xl">
Settings
</h1>
<p className="text-muted-foreground">
Manage your account settings and set e-mail preferences.
</p>
</div>
<Separator className="my-4 lg:my-6" />
<div className="flex flex-1 flex-col space-y-2 overflow-hidden md:space-y-2 lg:flex-row lg:space-x-12 lg:space-y-0">
<aside className="top-0 lg:sticky lg:w-1/5">
<SidebarNav items={sidebarNavItems} />
</aside>
<div className="flex w-full overflow-y-hidden p-1 pr-4">
<Outlet />
</div>
</div>
</Main>
</>
);
}
const sidebarNavItems = [
{
title: "Profile",
icon: <IconUser size={18} />,
href: "/settings"
},
{
title: "Account",
icon: <IconTool size={18} />,
href: "/settings/account"
},
{
title: "Appearance",
icon: <IconPalette size={18} />,
href: "/settings/appearance"
},
{
title: "Notifications",
icon: <IconNotification size={18} />,
href: "/settings/notifications"
},
{
title: "Display",
icon: <IconBrowserCheck size={18} />,
href: "/settings/display"
}
];
@@ -0,0 +1,12 @@
import ContentSection from "../components/content-section";
import { NotificationsForm } from "./notifications-form";
export default function SettingsNotifications() {
return (
<ContentSection
title="Notifications"
desc="Configure how you receive notifications.">
<NotificationsForm />
</ContentSection>
);
}
@@ -0,0 +1,223 @@
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Link } from "@tanstack/react-router";
import { toast } from "@/hooks/use-toast";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@/components/ui/form";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Switch } from "@/components/ui/switch";
const notificationsFormSchema = z.object({
type: z.enum(["all", "mentions", "none"], {
required_error: "You need to select a notification type."
}),
mobile: z.boolean().default(false).optional(),
communication_emails: z.boolean().default(false).optional(),
social_emails: z.boolean().default(false).optional(),
marketing_emails: z.boolean().default(false).optional(),
security_emails: z.boolean()
});
type NotificationsFormValues = z.infer<typeof notificationsFormSchema>;
// This can come from your database or API.
const defaultValues: Partial<NotificationsFormValues> = {
communication_emails: false,
marketing_emails: false,
social_emails: true,
security_emails: true
};
export function NotificationsForm() {
const form = useForm<NotificationsFormValues>({
resolver: zodResolver(notificationsFormSchema),
defaultValues
});
function onSubmit(data: NotificationsFormValues) {
toast({
title: "You submitted the following values:",
description: (
<pre className="mt-2 w-[340px] rounded-md bg-slate-950 p-4">
<code className="text-white">{JSON.stringify(data, null, 2)}</code>
</pre>
)
});
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
<FormField
control={form.control}
name="type"
render={({ field }) => (
<FormItem className="relative space-y-3">
<FormLabel>Notify me about...</FormLabel>
<FormControl>
<RadioGroup
onValueChange={field.onChange}
defaultValue={field.value}
className="flex flex-col space-y-1">
<FormItem className="flex items-center space-x-3 space-y-0">
<FormControl>
<RadioGroupItem value="all" />
</FormControl>
<FormLabel className="font-normal">
All new messages
</FormLabel>
</FormItem>
<FormItem className="flex items-center space-x-3 space-y-0">
<FormControl>
<RadioGroupItem value="mentions" />
</FormControl>
<FormLabel className="font-normal">
Direct messages and mentions
</FormLabel>
</FormItem>
<FormItem className="flex items-center space-x-3 space-y-0">
<FormControl>
<RadioGroupItem value="none" />
</FormControl>
<FormLabel className="font-normal">Nothing</FormLabel>
</FormItem>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="relative">
<h3 className="mb-4 text-lg font-medium">Email Notifications</h3>
<div className="space-y-4">
<FormField
control={form.control}
name="communication_emails"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel className="text-base">
Communication emails
</FormLabel>
<FormDescription>
Receive emails about your account activity.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="marketing_emails"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel className="text-base">
Marketing emails
</FormLabel>
<FormDescription>
Receive emails about new products, features, and more.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="social_emails"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel className="text-base">Social emails</FormLabel>
<FormDescription>
Receive emails for friend requests, follows, and more.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="security_emails"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel className="text-base">Security emails</FormLabel>
<FormDescription>
Receive emails about your account activity and security.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
disabled
aria-readonly
/>
</FormControl>
</FormItem>
)}
/>
</div>
</div>
<FormField
control={form.control}
name="mobile"
render={({ field }) => (
<FormItem className="relative flex flex-row items-start space-x-3 space-y-0">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>
Use different settings for my mobile devices
</FormLabel>
<FormDescription>
You can manage your mobile notifications in the{" "}
<Link
to="/settings"
className="underline decoration-dashed underline-offset-4 hover:decoration-solid">
mobile settings
</Link>{" "}
page.
</FormDescription>
</div>
</FormItem>
)}
/>
<Button type="submit">Update notifications</Button>
</form>
</Form>
);
}
@@ -0,0 +1,12 @@
import ContentSection from "../components/content-section";
import ProfileForm from "./profile-form";
export default function SettingsProfile() {
return (
<ContentSection
title="Profile"
desc="This is how others will see you on the site.">
<ProfileForm />
</ContentSection>
);
}
@@ -0,0 +1,187 @@
import { z } from "zod";
import { useFieldArray, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Link } from "@tanstack/react-router";
import { cn } from "@/lib/utils";
import { toast } from "@/hooks/use-toast";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
const profileFormSchema = z.object({
username: z
.string()
.min(2, {
message: "Username must be at least 2 characters."
})
.max(30, {
message: "Username must not be longer than 30 characters."
}),
email: z
.string({
required_error: "Please select an email to display."
})
.email(),
bio: z.string().max(160).min(4),
urls: z
.array(
z.object({
value: z.string().url({ message: "Please enter a valid URL." })
})
)
.optional()
});
type ProfileFormValues = z.infer<typeof profileFormSchema>;
// This can come from your database or API.
const defaultValues: Partial<ProfileFormValues> = {
bio: "I own a computer.",
urls: [
{ value: "https://shadcn.com" },
{ value: "http://twitter.com/shadcn" }
]
};
export default function ProfileForm() {
const form = useForm<ProfileFormValues>({
resolver: zodResolver(profileFormSchema),
defaultValues,
mode: "onChange"
});
const { fields, append } = useFieldArray({
name: "urls",
control: form.control
});
function onSubmit(data: ProfileFormValues) {
toast({
title: "You submitted the following values:",
description: (
<pre className="mt-2 w-[340px] rounded-md bg-slate-950 p-4">
<code className="text-white">{JSON.stringify(data, null, 2)}</code>
</pre>
)
});
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input placeholder="shadcn" {...field} />
</FormControl>
<FormDescription>
This is your public display name. It can be your real name or a
pseudonym. You can only change this once every 30 days.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a verified email to display" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="m@example.com">m@example.com</SelectItem>
<SelectItem value="m@google.com">m@google.com</SelectItem>
<SelectItem value="m@support.com">m@support.com</SelectItem>
</SelectContent>
</Select>
<FormDescription>
You can manage verified email addresses in your{" "}
<Link to="/">email settings</Link>.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="bio"
render={({ field }) => (
<FormItem>
<FormLabel>Bio</FormLabel>
<FormControl>
<Textarea
placeholder="Tell us a little bit about yourself"
className="resize-none"
{...field}
/>
</FormControl>
<FormDescription>
You can <span>@mention</span> other users and organizations to
link to them.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div>
{fields.map((field, index) => (
<FormField
control={form.control}
key={field.id}
name={`urls.${index}.value`}
render={({ field }) => (
<FormItem>
<FormLabel className={cn(index !== 0 && "sr-only")}>
URLs
</FormLabel>
<FormDescription className={cn(index !== 0 && "sr-only")}>
Add links to your website, blog, or social media profiles.
</FormDescription>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
))}
<Button
type="button"
variant="outline"
size="sm"
className="mt-2"
onClick={() => append({ value: "" })}>
Add URL
</Button>
</div>
<Button type="submit">Update profile</Button>
</form>
</Form>
);
}
@@ -0,0 +1,119 @@
import { ColumnDef } from "@tanstack/react-table";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { labels, priorities, statuses } from "../data/data";
import { Task } from "../data/schema";
import { DataTableColumnHeader } from "./data-table-column-header";
import { DataTableRowActions } from "./data-table-row-actions";
export const columns: ColumnDef<Task>[] = [
{
id: "select",
header: ({ table }) => (
<Checkbox
checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && "indeterminate")
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
className="translate-y-[2px]"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
className="translate-y-[2px]"
/>
),
enableSorting: false,
enableHiding: false
},
{
accessorKey: "id",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Task" />
),
cell: ({ row }) => <div className="w-[80px]">{row.getValue("id")}</div>,
enableSorting: false,
enableHiding: false
},
{
accessorKey: "title",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Title" />
),
cell: ({ row }) => {
const label = labels.find((label) => label.value === row.original.label);
return (
<div className="flex space-x-2">
{label && <Badge variant="outline">{label.label}</Badge>}
<span className="max-w-32 truncate font-medium sm:max-w-72 md:max-w-[31rem]">
{row.getValue("title")}
</span>
</div>
);
}
},
{
accessorKey: "status",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Status" />
),
cell: ({ row }) => {
const status = statuses.find(
(status) => status.value === row.getValue("status")
);
if (!status) {
return null;
}
return (
<div className="flex w-[100px] items-center">
{status.icon && (
<status.icon className="mr-2 h-4 w-4 text-muted-foreground" />
)}
<span>{status.label}</span>
</div>
);
},
filterFn: (row, id, value) => {
return value.includes(row.getValue(id));
}
},
{
accessorKey: "priority",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Priority" />
),
cell: ({ row }) => {
const priority = priorities.find(
(priority) => priority.value === row.getValue("priority")
);
if (!priority) {
return null;
}
return (
<div className="flex items-center">
{priority.icon && (
<priority.icon className="mr-2 h-4 w-4 text-muted-foreground" />
)}
<span>{priority.label}</span>
</div>
);
},
filterFn: (row, id, value) => {
return value.includes(row.getValue(id));
}
},
{
id: "actions",
cell: ({ row }) => <DataTableRowActions row={row} />
}
];
@@ -0,0 +1,73 @@
import {
ArrowDownIcon,
ArrowUpIcon,
CaretSortIcon,
EyeNoneIcon
} from "@radix-ui/react-icons";
import { Column } from "@tanstack/react-table";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from "@/components/ui/dropdown-menu";
interface DataTableColumnHeaderProps<TData, TValue>
extends React.HTMLAttributes<HTMLDivElement> {
column: Column<TData, TValue>;
title: string;
}
export function DataTableColumnHeader<TData, TValue>({
column,
title,
className
}: DataTableColumnHeaderProps<TData, TValue>) {
if (!column.getCanSort()) {
return <div className={cn(className)}>{title}</div>;
}
return (
<div className={cn("flex items-center space-x-2", className)}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="-ml-3 h-8 data-[state=open]:bg-accent">
<span>{title}</span>
{column.getIsSorted() === "desc" ? (
<ArrowDownIcon className="ml-2 h-4 w-4" />
) : column.getIsSorted() === "asc" ? (
<ArrowUpIcon className="ml-2 h-4 w-4" />
) : (
<CaretSortIcon className="ml-2 h-4 w-4" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem onClick={() => column.toggleSorting(false)}>
<ArrowUpIcon className="mr-2 h-3.5 w-3.5 text-muted-foreground/70" />
Asc
</DropdownMenuItem>
<DropdownMenuItem onClick={() => column.toggleSorting(true)}>
<ArrowDownIcon className="mr-2 h-3.5 w-3.5 text-muted-foreground/70" />
Desc
</DropdownMenuItem>
{column.getCanHide() && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => column.toggleVisibility(false)}>
<EyeNoneIcon className="mr-2 h-3.5 w-3.5 text-muted-foreground/70" />
Hide
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
@@ -0,0 +1,140 @@
import * as React from "react";
import { CheckIcon, PlusCircledIcon } from "@radix-ui/react-icons";
import { Column } from "@tanstack/react-table";
import { cn } from "@/lib/utils";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger
} from "@/components/ui/popover";
import { Separator } from "@/components/ui/separator";
interface DataTableFacetedFilterProps<TData, TValue> {
column?: Column<TData, TValue>;
title?: string;
options: {
label: string;
value: string;
icon?: React.ComponentType<{ className?: string }>;
}[];
}
export function DataTableFacetedFilter<TData, TValue>({
column,
title,
options
}: DataTableFacetedFilterProps<TData, TValue>) {
const facets = column?.getFacetedUniqueValues();
const selectedValues = new Set(column?.getFilterValue() as string[]);
return (
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="h-8 border-dashed">
<PlusCircledIcon className="mr-2 h-4 w-4" />
{title}
{selectedValues?.size > 0 && (
<>
<Separator orientation="vertical" className="mx-2 h-4" />
<Badge
variant="secondary"
className="rounded-sm px-1 font-normal lg:hidden">
{selectedValues.size}
</Badge>
<div className="hidden space-x-1 lg:flex">
{selectedValues.size > 2 ? (
<Badge
variant="secondary"
className="rounded-sm px-1 font-normal">
{selectedValues.size} selected
</Badge>
) : (
options
.filter((option) => selectedValues.has(option.value))
.map((option) => (
<Badge
variant="secondary"
key={option.value}
className="rounded-sm px-1 font-normal">
{option.label}
</Badge>
))
)}
</div>
</>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0" align="start">
<Command>
<CommandInput placeholder={title} />
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup>
{options.map((option) => {
const isSelected = selectedValues.has(option.value);
return (
<CommandItem
key={option.value}
onSelect={() => {
if (isSelected) {
selectedValues.delete(option.value);
} else {
selectedValues.add(option.value);
}
const filterValues = Array.from(selectedValues);
column?.setFilterValue(
filterValues.length ? filterValues : undefined
);
}}>
<div
className={cn(
"mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary",
isSelected
? "bg-primary text-primary-foreground"
: "opacity-50 [&_svg]:invisible"
)}>
<CheckIcon className={cn("h-4 w-4")} />
</div>
{option.icon && (
<option.icon className="mr-2 h-4 w-4 text-muted-foreground" />
)}
<span>{option.label}</span>
{facets?.get(option.value) && (
<span className="ml-auto flex h-4 w-4 items-center justify-center font-mono text-xs">
{facets.get(option.value)}
</span>
)}
</CommandItem>
);
})}
</CommandGroup>
{selectedValues.size > 0 && (
<>
<CommandSeparator />
<CommandGroup>
<CommandItem
onSelect={() => column?.setFilterValue(undefined)}
className="justify-center text-center">
Clear filters
</CommandItem>
</CommandGroup>
</>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
@@ -0,0 +1,93 @@
import {
ChevronLeftIcon,
ChevronRightIcon,
DoubleArrowLeftIcon,
DoubleArrowRightIcon
} from "@radix-ui/react-icons";
import { Table } from "@tanstack/react-table";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@/components/ui/select";
interface DataTablePaginationProps<TData> {
table: Table<TData>;
}
export function DataTablePagination<TData>({
table
}: DataTablePaginationProps<TData>) {
return (
<div
className="flex items-center justify-between overflow-clip px-2"
style={{ overflowClipMargin: 1 }}>
<div className="hidden flex-1 text-sm text-muted-foreground sm:block">
{table.getFilteredSelectedRowModel().rows.length} of{" "}
{table.getFilteredRowModel().rows.length} row(s) selected.
</div>
<div className="flex items-center sm:space-x-6 lg:space-x-8">
<div className="flex items-center space-x-2">
<p className="hidden text-sm font-medium sm:block">Rows per page</p>
<Select
value={`${table.getState().pagination.pageSize}`}
onValueChange={(value) => {
table.setPageSize(Number(value));
}}>
<SelectTrigger className="h-8 w-[70px]">
<SelectValue placeholder={table.getState().pagination.pageSize} />
</SelectTrigger>
<SelectContent side="top">
{[10, 20, 30, 40, 50].map((pageSize) => (
<SelectItem key={pageSize} value={`${pageSize}`}>
{pageSize}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex w-[100px] items-center justify-center text-sm font-medium">
Page {table.getState().pagination.pageIndex + 1} of{" "}
{table.getPageCount()}
</div>
<div className="flex items-center space-x-2">
<Button
variant="outline"
className="hidden h-8 w-8 p-0 lg:flex"
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}>
<span className="sr-only">Go to first page</span>
<DoubleArrowLeftIcon className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="h-8 w-8 p-0"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}>
<span className="sr-only">Go to previous page</span>
<ChevronLeftIcon className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="h-8 w-8 p-0"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}>
<span className="sr-only">Go to next page</span>
<ChevronRightIcon className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="hidden h-8 w-8 p-0 lg:flex"
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}>
<span className="sr-only">Go to last page</span>
<DoubleArrowRightIcon className="h-4 w-4" />
</Button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,80 @@
import { DotsHorizontalIcon } from "@radix-ui/react-icons";
import { Row } from "@tanstack/react-table";
import { IconTrash } from "@tabler/icons-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger
} from "@/components/ui/dropdown-menu";
import { useTasks } from "../context/tasks-context";
import { labels } from "../data/data";
import { taskSchema } from "../data/schema";
interface DataTableRowActionsProps<TData> {
row: Row<TData>;
}
export function DataTableRowActions<TData>({
row
}: DataTableRowActionsProps<TData>) {
const task = taskSchema.parse(row.original);
const { setOpen, setCurrentRow } = useTasks();
return (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="flex h-8 w-8 p-0 data-[state=open]:bg-muted">
<DotsHorizontalIcon className="h-4 w-4" />
<span className="sr-only">Open menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[160px]">
<DropdownMenuItem
onClick={() => {
setCurrentRow(task);
setOpen("update");
}}>
Edit
</DropdownMenuItem>
<DropdownMenuItem disabled>Make a copy</DropdownMenuItem>
<DropdownMenuItem disabled>Favorite</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuSub>
<DropdownMenuSubTrigger>Labels</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuRadioGroup value={task.label}>
{labels.map((label) => (
<DropdownMenuRadioItem key={label.value} value={label.value}>
{label.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => {
setCurrentRow(task);
setOpen("delete");
}}>
Delete
<DropdownMenuShortcut>
<IconTrash size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -0,0 +1,58 @@
import { Cross2Icon } from "@radix-ui/react-icons";
import { Table } from "@tanstack/react-table";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { DataTableViewOptions } from "../components/data-table-view-options";
import { priorities, statuses } from "../data/data";
import { DataTableFacetedFilter } from "./data-table-faceted-filter";
interface DataTableToolbarProps<TData> {
table: Table<TData>;
}
export function DataTableToolbar<TData>({
table
}: DataTableToolbarProps<TData>) {
const isFiltered = table.getState().columnFilters.length > 0;
return (
<div className="flex items-center justify-between">
<div className="flex flex-1 flex-col-reverse items-start gap-y-2 sm:flex-row sm:items-center sm:space-x-2">
<Input
placeholder="Filter tasks..."
value={(table.getColumn("title")?.getFilterValue() as string) ?? ""}
onChange={(event) =>
table.getColumn("title")?.setFilterValue(event.target.value)
}
className="h-8 w-[150px] lg:w-[250px]"
/>
<div className="flex gap-x-2">
{table.getColumn("status") && (
<DataTableFacetedFilter
column={table.getColumn("status")}
title="Status"
options={statuses}
/>
)}
{table.getColumn("priority") && (
<DataTableFacetedFilter
column={table.getColumn("priority")}
title="Priority"
options={priorities}
/>
)}
</div>
{isFiltered && (
<Button
variant="ghost"
onClick={() => table.resetColumnFilters()}
className="h-8 px-2 lg:px-3">
Reset
<Cross2Icon className="ml-2 h-4 w-4" />
</Button>
)}
</div>
<DataTableViewOptions table={table} />
</div>
);
}
@@ -0,0 +1,54 @@
import { DropdownMenuTrigger } from "@radix-ui/react-dropdown-menu";
import { MixerHorizontalIcon } from "@radix-ui/react-icons";
import { Table } from "@tanstack/react-table";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuSeparator
} from "@/components/ui/dropdown-menu";
interface DataTableViewOptionsProps<TData> {
table: Table<TData>;
}
export function DataTableViewOptions<TData>({
table
}: DataTableViewOptionsProps<TData>) {
return (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="ml-auto hidden h-8 lg:flex">
<MixerHorizontalIcon className="mr-2 h-4 w-4" />
View
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[150px]">
<DropdownMenuLabel>Toggle columns</DropdownMenuLabel>
<DropdownMenuSeparator />
{table
.getAllColumns()
.filter(
(column) =>
typeof column.accessorFn !== "undefined" && column.getCanHide()
)
.map((column) => {
return (
<DropdownMenuCheckboxItem
key={column.id}
className="capitalize"
checked={column.getIsVisible()}
onCheckedChange={(value) => column.toggleVisibility(!!value)}>
{column.id}
</DropdownMenuCheckboxItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -0,0 +1,120 @@
import * as React from "react";
import {
ColumnDef,
ColumnFiltersState,
SortingState,
VisibilityState,
flexRender,
getCoreRowModel,
getFacetedRowModel,
getFacetedUniqueValues,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable
} from "@tanstack/react-table";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from "@/components/ui/table";
import { DataTablePagination } from "../components/data-table-pagination";
import { DataTableToolbar } from "../components/data-table-toolbar";
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
}
export function DataTable<TData, TValue>({
columns,
data
}: DataTableProps<TData, TValue>) {
const [rowSelection, setRowSelection] = React.useState({});
const [columnVisibility, setColumnVisibility] =
React.useState<VisibilityState>({});
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
[]
);
const [sorting, setSorting] = React.useState<SortingState>([]);
const table = useReactTable({
data,
columns,
state: {
sorting,
columnVisibility,
rowSelection,
columnFilters
},
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
onColumnVisibilityChange: setColumnVisibility,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFacetedRowModel: getFacetedRowModel(),
getFacetedUniqueValues: getFacetedUniqueValues()
});
return (
<div className="space-y-4">
<DataTableToolbar table={table} />
<div className="rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id} colSpan={header.colSpan}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center">
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<DataTablePagination table={table} />
</div>
);
}
@@ -0,0 +1,78 @@
import { toast } from "@/hooks/use-toast";
import { ConfirmDialog } from "@/components/confirm-dialog";
import { useTasks } from "../context/tasks-context";
import { TasksImportDialog } from "./tasks-import-dialog";
import { TasksMutateDrawer } from "./tasks-mutate-drawer";
export function TasksDialogs() {
const { open, setOpen, currentRow, setCurrentRow } = useTasks();
return (
<>
<TasksMutateDrawer
key="task-create"
open={open === "create"}
onOpenChange={() => setOpen("create")}
/>
<TasksImportDialog
key="tasks-import"
open={open === "import"}
onOpenChange={() => setOpen("import")}
/>
{currentRow && (
<>
<TasksMutateDrawer
key={`task-update-${currentRow.id}`}
open={open === "update"}
onOpenChange={() => {
setOpen("update");
setTimeout(() => {
setCurrentRow(null);
}, 500);
}}
currentRow={currentRow}
/>
<ConfirmDialog
key="task-delete"
destructive
open={open === "delete"}
onOpenChange={() => {
setOpen("delete");
setTimeout(() => {
setCurrentRow(null);
}, 500);
}}
handleConfirm={() => {
setOpen(null);
setTimeout(() => {
setCurrentRow(null);
}, 500);
toast({
title: "The following task has been deleted:",
description: (
<pre className="mt-2 w-[340px] rounded-md bg-slate-950 p-4">
<code className="text-white">
{JSON.stringify(currentRow, null, 2)}
</code>
</pre>
)
});
}}
className="max-w-md"
title={`Delete this task: ${currentRow.id} ?`}
desc={
<>
You are about to delete a task with the ID{" "}
<strong>{currentRow.id}</strong>. <br />
This action cannot be undone.
</>
}
confirmText="Delete"
/>
</>
)}
</>
);
}
@@ -0,0 +1,115 @@
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { toast } from "@/hooks/use-toast";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
const formSchema = z.object({
file: z
.instanceof(FileList)
.refine((files) => files.length > 0, {
message: "Please upload a file"
})
.refine(
(files) => ["text/csv"].includes(files?.[0]?.type),
"Please upload csv format."
)
});
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function TasksImportDialog({ open, onOpenChange }: Props) {
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: { file: undefined }
});
const fileRef = form.register("file");
const onSubmit = () => {
const file = form.getValues("file");
if (file && file[0]) {
const fileDetails = {
name: file[0].name,
size: file[0].size,
type: file[0].type
};
toast({
title: "You have imported the following file:",
description: (
<pre className="mt-2 w-[340px] rounded-md bg-slate-950 p-4">
<code className="text-white">
{JSON.stringify(fileDetails, null, 2)}
</code>
</pre>
)
});
}
onOpenChange(false);
};
return (
<Dialog
open={open}
onOpenChange={(val) => {
onOpenChange(val);
form.reset();
}}>
<DialogContent className="gap-2 sm:max-w-sm">
<DialogHeader className="text-left">
<DialogTitle>Import Tasks</DialogTitle>
<DialogDescription>
Import tasks quickly from a CSV file.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form id="task-import-form" onSubmit={form.handleSubmit(onSubmit)}>
<FormField
control={form.control}
name="file"
render={() => (
<FormItem className="mb-2 space-y-1">
<FormLabel>File</FormLabel>
<FormControl>
<Input type="file" {...fileRef} className="h-8" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
<DialogFooter className="gap-2 sm:gap-0">
<DialogClose asChild>
<Button variant="outline">Close</Button>
</DialogClose>
<Button type="submit" form="task-import-form">
Import
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,211 @@
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { toast } from "@/hooks/use-toast";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle
} from "@/components/ui/sheet";
import { SelectDropdown } from "@/components/select-dropdown";
import { Task } from "../data/schema";
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
currentRow?: Task;
}
const formSchema = z.object({
title: z.string().min(1, "Title is required."),
status: z.string().min(1, "Please select a status."),
label: z.string().min(1, "Please select a label."),
priority: z.string().min(1, "Please choose a priority.")
});
type TasksForm = z.infer<typeof formSchema>;
export function TasksMutateDrawer({ open, onOpenChange, currentRow }: Props) {
const isUpdate = !!currentRow;
const form = useForm<TasksForm>({
resolver: zodResolver(formSchema),
defaultValues: currentRow ?? {
title: "",
status: "",
label: "",
priority: ""
}
});
const onSubmit = (data: TasksForm) => {
// do something with the form data
onOpenChange(false);
form.reset();
toast({
title: "You submitted the following values:",
description: (
<pre className="mt-2 w-[340px] rounded-md bg-slate-950 p-4">
<code className="text-white">{JSON.stringify(data, null, 2)}</code>
</pre>
)
});
};
return (
<Sheet
open={open}
onOpenChange={(v) => {
onOpenChange(v);
form.reset();
}}>
<SheetContent className="flex flex-col">
<SheetHeader className="text-left">
<SheetTitle>{isUpdate ? "Update" : "Create"} Task</SheetTitle>
<SheetDescription>
{isUpdate
? "Update the task by providing necessary info."
: "Add a new task by providing necessary info."}
Click save when you&apos;re done.
</SheetDescription>
</SheetHeader>
<Form {...form}>
<form
id="tasks-form"
onSubmit={form.handleSubmit(onSubmit)}
className="flex-1 space-y-5">
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem className="space-y-1">
<FormLabel>Title</FormLabel>
<FormControl>
<Input {...field} placeholder="Enter a title" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="status"
render={({ field }) => (
<FormItem className="space-y-1">
<FormLabel>Status</FormLabel>
<SelectDropdown
defaultValue={field.value}
onValueChange={field.onChange}
placeholder="Select dropdown"
items={[
{ label: "In Progress", value: "in progress" },
{ label: "Backlog", value: "backlog" },
{ label: "Todo", value: "todo" },
{ label: "Canceled", value: "canceled" },
{ label: "Done", value: "done" }
]}
/>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="label"
render={({ field }) => (
<FormItem className="relative space-y-3">
<FormLabel>Label</FormLabel>
<FormControl>
<RadioGroup
onValueChange={field.onChange}
defaultValue={field.value}
className="flex flex-col space-y-1">
<FormItem className="flex items-center space-x-3 space-y-0">
<FormControl>
<RadioGroupItem value="documentation" />
</FormControl>
<FormLabel className="font-normal">
Documentation
</FormLabel>
</FormItem>
<FormItem className="flex items-center space-x-3 space-y-0">
<FormControl>
<RadioGroupItem value="feature" />
</FormControl>
<FormLabel className="font-normal">Feature</FormLabel>
</FormItem>
<FormItem className="flex items-center space-x-3 space-y-0">
<FormControl>
<RadioGroupItem value="bug" />
</FormControl>
<FormLabel className="font-normal">Bug</FormLabel>
</FormItem>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="priority"
render={({ field }) => (
<FormItem className="relative space-y-3">
<FormLabel>Priority</FormLabel>
<FormControl>
<RadioGroup
onValueChange={field.onChange}
defaultValue={field.value}
className="flex flex-col space-y-1">
<FormItem className="flex items-center space-x-3 space-y-0">
<FormControl>
<RadioGroupItem value="high" />
</FormControl>
<FormLabel className="font-normal">High</FormLabel>
</FormItem>
<FormItem className="flex items-center space-x-3 space-y-0">
<FormControl>
<RadioGroupItem value="medium" />
</FormControl>
<FormLabel className="font-normal">Medium</FormLabel>
</FormItem>
<FormItem className="flex items-center space-x-3 space-y-0">
<FormControl>
<RadioGroupItem value="low" />
</FormControl>
<FormLabel className="font-normal">Low</FormLabel>
</FormItem>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
<SheetFooter className="gap-2">
<SheetClose asChild>
<Button variant="outline">Close</Button>
</SheetClose>
<Button form="tasks-form" type="submit">
Save changes
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,20 @@
import { IconDownload, IconPlus } from "@tabler/icons-react";
import { Button } from "@/components/ui/button";
import { useTasks } from "../context/tasks-context";
export function TasksPrimaryButtons() {
const { setOpen } = useTasks();
return (
<div className="flex gap-2">
<Button
variant="outline"
className="space-x-1"
onClick={() => setOpen("import")}>
<span>Import</span> <IconDownload size={18} />
</Button>
<Button className="space-x-1" onClick={() => setOpen("create")}>
<span>Create</span> <IconPlus size={18} />
</Button>
</div>
);
}
@@ -0,0 +1,39 @@
import React, { useState } from "react";
import useDialogState from "@/hooks/use-dialog-state";
import { Task } from "../data/schema";
type TasksDialogType = "create" | "update" | "delete" | "import";
interface TasksContextType {
open: TasksDialogType | null;
setOpen: (str: TasksDialogType | null) => void;
currentRow: Task | null;
setCurrentRow: React.Dispatch<React.SetStateAction<Task | null>>;
}
const TasksContext = React.createContext<TasksContextType | null>(null);
interface Props {
children: React.ReactNode;
}
export default function TasksProvider({ children }: Props) {
const [open, setOpen] = useDialogState<TasksDialogType>(null);
const [currentRow, setCurrentRow] = useState<Task | null>(null);
return (
<TasksContext value={{ open, setOpen, currentRow, setCurrentRow }}>
{children}
</TasksContext>
);
}
// eslint-disable-next-line react-refresh/only-export-components
export const useTasks = () => {
const tasksContext = React.useContext(TasksContext);
if (!tasksContext) {
throw new Error("useTasks has to be used within <TasksContext>");
}
return tasksContext;
};
+71
View File
@@ -0,0 +1,71 @@
import {
IconArrowDown,
IconArrowRight,
IconArrowUp,
IconCircle,
IconCircleCheck,
IconCircleX,
IconExclamationCircle,
IconStopwatch
} from "@tabler/icons-react";
export const labels = [
{
value: "bug",
label: "Bug"
},
{
value: "feature",
label: "Feature"
},
{
value: "documentation",
label: "Documentation"
}
];
export const statuses = [
{
value: "backlog",
label: "Backlog",
icon: IconExclamationCircle
},
{
value: "todo",
label: "Todo",
icon: IconCircle
},
{
value: "in progress",
label: "In Progress",
icon: IconStopwatch
},
{
value: "done",
label: "Done",
icon: IconCircleCheck
},
{
value: "canceled",
label: "Canceled",
icon: IconCircleX
}
];
export const priorities = [
{
label: "Low",
value: "low",
icon: IconArrowDown
},
{
label: "Medium",
value: "medium",
icon: IconArrowRight
},
{
label: "High",
value: "high",
icon: IconArrowUp
}
];
+13
View File
@@ -0,0 +1,13 @@
import { z } from "zod";
// We're keeping a simple non-relational schema here.
// IRL, you will have a schema for your data models.
export const taskSchema = z.object({
id: z.string(),
title: z.string(),
status: z.string(),
label: z.string(),
priority: z.string()
});
export type Task = z.infer<typeof taskSchema>;
+782
View File
@@ -0,0 +1,782 @@
export const tasks = [
{
id: "TASK-8782",
title:
"You can't compress the program without quantifying the open-source SSD pixel!",
status: "in progress",
label: "documentation",
priority: "medium"
},
{
id: "TASK-7878",
title:
"Try to calculate the EXE feed, maybe it will index the multi-byte pixel!",
status: "backlog",
label: "documentation",
priority: "medium"
},
{
id: "TASK-7839",
title: "We need to bypass the neural TCP card!",
status: "todo",
label: "bug",
priority: "high"
},
{
id: "TASK-5562",
title:
"The SAS interface is down, bypass the open-source pixel so we can back up the PNG bandwidth!",
status: "backlog",
label: "feature",
priority: "medium"
},
{
id: "TASK-8686",
title:
"I'll parse the wireless SSL protocol, that should driver the API panel!",
status: "canceled",
label: "feature",
priority: "medium"
},
{
id: "TASK-1280",
title:
"Use the digital TLS panel, then you can transmit the haptic system!",
status: "done",
label: "bug",
priority: "high"
},
{
id: "TASK-7262",
title:
"The UTF8 application is down, parse the neural bandwidth so we can back up the PNG firewall!",
status: "done",
label: "feature",
priority: "high"
},
{
id: "TASK-1138",
title:
"Generating the driver won't do anything, we need to quantify the 1080p SMTP bandwidth!",
status: "in progress",
label: "feature",
priority: "medium"
},
{
id: "TASK-7184",
title: "We need to program the back-end THX pixel!",
status: "todo",
label: "feature",
priority: "low"
},
{
id: "TASK-5160",
title:
"Calculating the bus won't do anything, we need to navigate the back-end JSON protocol!",
status: "in progress",
label: "documentation",
priority: "high"
},
{
id: "TASK-5618",
title:
"Generating the driver won't do anything, we need to index the online SSL application!",
status: "done",
label: "documentation",
priority: "medium"
},
{
id: "TASK-6699",
title:
"I'll transmit the wireless JBOD capacitor, that should hard drive the SSD feed!",
status: "backlog",
label: "documentation",
priority: "medium"
},
{
id: "TASK-2858",
title: "We need to override the online UDP bus!",
status: "backlog",
label: "bug",
priority: "medium"
},
{
id: "TASK-9864",
title:
"I'll reboot the 1080p FTP panel, that should matrix the HEX hard drive!",
status: "done",
label: "bug",
priority: "high"
},
{
id: "TASK-8404",
title: "We need to generate the virtual HEX alarm!",
status: "in progress",
label: "bug",
priority: "low"
},
{
id: "TASK-5365",
title:
"Backing up the pixel won't do anything, we need to transmit the primary IB array!",
status: "in progress",
label: "documentation",
priority: "low"
},
{
id: "TASK-1780",
title:
"The CSS feed is down, index the bluetooth transmitter so we can compress the CLI protocol!",
status: "todo",
label: "documentation",
priority: "high"
},
{
id: "TASK-6938",
title:
"Use the redundant SCSI application, then you can hack the optical alarm!",
status: "todo",
label: "documentation",
priority: "high"
},
{
id: "TASK-9885",
title: "We need to compress the auxiliary VGA driver!",
status: "backlog",
label: "bug",
priority: "high"
},
{
id: "TASK-3216",
title:
"Transmitting the transmitter won't do anything, we need to compress the virtual HDD sensor!",
status: "backlog",
label: "documentation",
priority: "medium"
},
{
id: "TASK-9285",
title:
"The IP monitor is down, copy the haptic alarm so we can generate the HTTP transmitter!",
status: "todo",
label: "bug",
priority: "high"
},
{
id: "TASK-1024",
title:
"Overriding the microchip won't do anything, we need to transmit the digital OCR transmitter!",
status: "in progress",
label: "documentation",
priority: "low"
},
{
id: "TASK-7068",
title:
"You can't generate the capacitor without indexing the wireless HEX pixel!",
status: "canceled",
label: "bug",
priority: "low"
},
{
id: "TASK-6502",
title:
"Navigating the microchip won't do anything, we need to bypass the back-end SQL bus!",
status: "todo",
label: "bug",
priority: "high"
},
{
id: "TASK-5326",
title: "We need to hack the redundant UTF8 transmitter!",
status: "todo",
label: "bug",
priority: "low"
},
{
id: "TASK-6274",
title:
"Use the virtual PCI circuit, then you can parse the bluetooth alarm!",
status: "canceled",
label: "documentation",
priority: "low"
},
{
id: "TASK-1571",
title:
"I'll input the neural DRAM circuit, that should protocol the SMTP interface!",
status: "in progress",
label: "feature",
priority: "medium"
},
{
id: "TASK-9518",
title:
"Compressing the interface won't do anything, we need to compress the online SDD matrix!",
status: "canceled",
label: "documentation",
priority: "medium"
},
{
id: "TASK-5581",
title:
"I'll synthesize the digital COM pixel, that should transmitter the UTF8 protocol!",
status: "backlog",
label: "documentation",
priority: "high"
},
{
id: "TASK-2197",
title:
"Parsing the feed won't do anything, we need to copy the bluetooth DRAM bus!",
status: "todo",
label: "documentation",
priority: "low"
},
{
id: "TASK-8484",
title: "We need to parse the solid state UDP firewall!",
status: "in progress",
label: "bug",
priority: "low"
},
{
id: "TASK-9892",
title:
"If we back up the application, we can get to the UDP application through the multi-byte THX capacitor!",
status: "done",
label: "documentation",
priority: "high"
},
{
id: "TASK-9616",
title: "We need to synthesize the cross-platform ASCII pixel!",
status: "in progress",
label: "feature",
priority: "medium"
},
{
id: "TASK-9744",
title:
"Use the back-end IP card, then you can input the solid state hard drive!",
status: "done",
label: "documentation",
priority: "low"
},
{
id: "TASK-1376",
title:
"Generating the alarm won't do anything, we need to generate the mobile IP capacitor!",
status: "backlog",
label: "documentation",
priority: "low"
},
{
id: "TASK-7382",
title:
"If we back up the firewall, we can get to the RAM alarm through the primary UTF8 pixel!",
status: "todo",
label: "feature",
priority: "low"
},
{
id: "TASK-2290",
title:
"I'll compress the virtual JSON panel, that should application the UTF8 bus!",
status: "canceled",
label: "documentation",
priority: "high"
},
{
id: "TASK-1533",
title:
"You can't input the firewall without overriding the wireless TCP firewall!",
status: "done",
label: "bug",
priority: "high"
},
{
id: "TASK-4920",
title:
"Bypassing the hard drive won't do anything, we need to input the bluetooth JSON program!",
status: "in progress",
label: "bug",
priority: "high"
},
{
id: "TASK-5168",
title:
"If we synthesize the bus, we can get to the IP panel through the virtual TLS array!",
status: "in progress",
label: "feature",
priority: "low"
},
{
id: "TASK-7103",
title: "We need to parse the multi-byte EXE bandwidth!",
status: "canceled",
label: "feature",
priority: "low"
},
{
id: "TASK-4314",
title:
"If we compress the program, we can get to the XML alarm through the multi-byte COM matrix!",
status: "in progress",
label: "bug",
priority: "high"
},
{
id: "TASK-3415",
title:
"Use the cross-platform XML application, then you can quantify the solid state feed!",
status: "todo",
label: "feature",
priority: "high"
},
{
id: "TASK-8339",
title:
"Try to calculate the DNS interface, maybe it will input the bluetooth capacitor!",
status: "in progress",
label: "feature",
priority: "low"
},
{
id: "TASK-6995",
title:
"Try to hack the XSS bandwidth, maybe it will override the bluetooth matrix!",
status: "todo",
label: "feature",
priority: "high"
},
{
id: "TASK-8053",
title:
"If we connect the program, we can get to the UTF8 matrix through the digital UDP protocol!",
status: "todo",
label: "feature",
priority: "medium"
},
{
id: "TASK-4336",
title:
"If we synthesize the microchip, we can get to the SAS sensor through the optical UDP program!",
status: "todo",
label: "documentation",
priority: "low"
},
{
id: "TASK-8790",
title:
"I'll back up the optical COM alarm, that should alarm the RSS capacitor!",
status: "done",
label: "bug",
priority: "medium"
},
{
id: "TASK-8980",
title:
"Try to navigate the SQL transmitter, maybe it will back up the virtual firewall!",
status: "canceled",
label: "bug",
priority: "low"
},
{
id: "TASK-7342",
title: "Use the neural CLI card, then you can parse the online port!",
status: "backlog",
label: "documentation",
priority: "low"
},
{
id: "TASK-5608",
title:
"I'll hack the haptic SSL program, that should bus the UDP transmitter!",
status: "canceled",
label: "documentation",
priority: "low"
},
{
id: "TASK-1606",
title:
"I'll generate the bluetooth PNG firewall, that should pixel the SSL driver!",
status: "done",
label: "feature",
priority: "medium"
},
{
id: "TASK-7872",
title:
"Transmitting the circuit won't do anything, we need to reboot the 1080p RSS monitor!",
status: "canceled",
label: "feature",
priority: "medium"
},
{
id: "TASK-4167",
title:
"Use the cross-platform SMS circuit, then you can synthesize the optical feed!",
status: "canceled",
label: "bug",
priority: "medium"
},
{
id: "TASK-9581",
title:
"You can't index the port without hacking the cross-platform XSS monitor!",
status: "backlog",
label: "documentation",
priority: "low"
},
{
id: "TASK-8806",
title: "We need to bypass the back-end SSL panel!",
status: "done",
label: "bug",
priority: "medium"
},
{
id: "TASK-6542",
title:
"Try to quantify the RSS firewall, maybe it will quantify the open-source system!",
status: "done",
label: "feature",
priority: "low"
},
{
id: "TASK-6806",
title:
"The VGA protocol is down, reboot the back-end matrix so we can parse the CSS panel!",
status: "canceled",
label: "documentation",
priority: "low"
},
{
id: "TASK-9549",
title: "You can't bypass the bus without connecting the neural JBOD bus!",
status: "todo",
label: "feature",
priority: "high"
},
{
id: "TASK-1075",
title:
"Backing up the driver won't do anything, we need to parse the redundant RAM pixel!",
status: "done",
label: "feature",
priority: "medium"
},
{
id: "TASK-1427",
title:
"Use the auxiliary PCI circuit, then you can calculate the cross-platform interface!",
status: "done",
label: "documentation",
priority: "high"
},
{
id: "TASK-1907",
title:
"Hacking the circuit won't do anything, we need to back up the online DRAM system!",
status: "todo",
label: "documentation",
priority: "high"
},
{
id: "TASK-4309",
title:
"If we generate the system, we can get to the TCP sensor through the optical GB pixel!",
status: "backlog",
label: "bug",
priority: "medium"
},
{
id: "TASK-3973",
title:
"I'll parse the back-end ADP array, that should bandwidth the RSS bandwidth!",
status: "todo",
label: "feature",
priority: "medium"
},
{
id: "TASK-7962",
title:
"Use the wireless RAM program, then you can hack the cross-platform feed!",
status: "canceled",
label: "bug",
priority: "low"
},
{
id: "TASK-3360",
title:
"You can't quantify the program without synthesizing the neural OCR interface!",
status: "done",
label: "feature",
priority: "medium"
},
{
id: "TASK-9887",
title:
"Use the auxiliary ASCII sensor, then you can connect the solid state port!",
status: "backlog",
label: "bug",
priority: "medium"
},
{
id: "TASK-3649",
title:
"I'll input the virtual USB system, that should circuit the DNS monitor!",
status: "in progress",
label: "feature",
priority: "medium"
},
{
id: "TASK-3586",
title:
"If we quantify the circuit, we can get to the CLI feed through the mobile SMS hard drive!",
status: "in progress",
label: "bug",
priority: "low"
},
{
id: "TASK-5150",
title:
"I'll hack the wireless XSS port, that should transmitter the IP interface!",
status: "canceled",
label: "feature",
priority: "medium"
},
{
id: "TASK-3652",
title:
"The SQL interface is down, override the optical bus so we can program the ASCII interface!",
status: "backlog",
label: "feature",
priority: "low"
},
{
id: "TASK-6884",
title:
"Use the digital PCI circuit, then you can synthesize the multi-byte microchip!",
status: "canceled",
label: "feature",
priority: "high"
},
{
id: "TASK-1591",
title: "We need to connect the mobile XSS driver!",
status: "in progress",
label: "feature",
priority: "high"
},
{
id: "TASK-3802",
title:
"Try to override the ASCII protocol, maybe it will parse the virtual matrix!",
status: "in progress",
label: "feature",
priority: "low"
},
{
id: "TASK-7253",
title:
"Programming the capacitor won't do anything, we need to bypass the neural IB hard drive!",
status: "backlog",
label: "bug",
priority: "high"
},
{
id: "TASK-9739",
title: "We need to hack the multi-byte HDD bus!",
status: "done",
label: "documentation",
priority: "medium"
},
{
id: "TASK-4424",
title:
"Try to hack the HEX alarm, maybe it will connect the optical pixel!",
status: "in progress",
label: "documentation",
priority: "medium"
},
{
id: "TASK-3922",
title:
"You can't back up the capacitor without generating the wireless PCI program!",
status: "backlog",
label: "bug",
priority: "low"
},
{
id: "TASK-4921",
title:
"I'll index the open-source IP feed, that should system the GB application!",
status: "canceled",
label: "bug",
priority: "low"
},
{
id: "TASK-5814",
title: "We need to calculate the 1080p AGP feed!",
status: "backlog",
label: "bug",
priority: "high"
},
{
id: "TASK-2645",
title:
"Synthesizing the system won't do anything, we need to navigate the multi-byte HDD firewall!",
status: "todo",
label: "documentation",
priority: "medium"
},
{
id: "TASK-4535",
title:
"Try to copy the JSON circuit, maybe it will connect the wireless feed!",
status: "in progress",
label: "feature",
priority: "low"
},
{
id: "TASK-4463",
title: "We need to copy the solid state AGP monitor!",
status: "done",
label: "documentation",
priority: "low"
},
{
id: "TASK-9745",
title:
"If we connect the protocol, we can get to the GB system through the bluetooth PCI microchip!",
status: "canceled",
label: "feature",
priority: "high"
},
{
id: "TASK-2080",
title:
"If we input the bus, we can get to the RAM matrix through the auxiliary RAM card!",
status: "todo",
label: "bug",
priority: "medium"
},
{
id: "TASK-3838",
title:
"I'll bypass the online TCP application, that should panel the AGP system!",
status: "backlog",
label: "bug",
priority: "high"
},
{
id: "TASK-1340",
title: "We need to navigate the virtual PNG circuit!",
status: "todo",
label: "bug",
priority: "medium"
},
{
id: "TASK-6665",
title:
"If we parse the monitor, we can get to the SSD hard drive through the cross-platform AGP alarm!",
status: "canceled",
label: "feature",
priority: "low"
},
{
id: "TASK-7585",
title:
"If we calculate the hard drive, we can get to the SSL program through the multi-byte CSS microchip!",
status: "backlog",
label: "feature",
priority: "low"
},
{
id: "TASK-6319",
title: "We need to copy the multi-byte SCSI program!",
status: "backlog",
label: "bug",
priority: "high"
},
{
id: "TASK-4369",
title: "Try to input the SCSI bus, maybe it will generate the 1080p pixel!",
status: "backlog",
label: "bug",
priority: "high"
},
{
id: "TASK-9035",
title: "We need to override the solid state PNG array!",
status: "canceled",
label: "documentation",
priority: "low"
},
{
id: "TASK-3970",
title:
"You can't index the transmitter without quantifying the haptic ASCII card!",
status: "todo",
label: "documentation",
priority: "medium"
},
{
id: "TASK-4473",
title:
"You can't bypass the protocol without overriding the neural RSS program!",
status: "todo",
label: "documentation",
priority: "low"
},
{
id: "TASK-4136",
title:
"You can't hack the hard drive without hacking the primary JSON program!",
status: "canceled",
label: "bug",
priority: "medium"
},
{
id: "TASK-3939",
title:
"Use the back-end SQL firewall, then you can connect the neural hard drive!",
status: "done",
label: "feature",
priority: "low"
},
{
id: "TASK-2007",
title:
"I'll input the back-end USB protocol, that should bandwidth the PCI system!",
status: "backlog",
label: "bug",
priority: "high"
},
{
id: "TASK-7516",
title:
"Use the primary SQL program, then you can generate the auxiliary transmitter!",
status: "done",
label: "documentation",
priority: "medium"
},
{
id: "TASK-6906",
title:
"Try to back up the DRAM system, maybe it will reboot the online transmitter!",
status: "done",
label: "feature",
priority: "high"
},
{
id: "TASK-5207",
title:
"The SMS interface is down, copy the bluetooth bus so we can quantify the VGA card!",
status: "in progress",
label: "bug",
priority: "low"
}
];
+42
View File
@@ -0,0 +1,42 @@
import { Header } from "@/components/layout/header";
import { Main } from "@/components/layout/main";
import { ProfileDropdown } from "@/components/profile-dropdown";
import { Search } from "@/components/search";
import { ThemeSwitch } from "@/components/theme-switch";
import { columns } from "./components/columns";
import { DataTable } from "./components/data-table";
import { TasksDialogs } from "./components/tasks-dialogs";
import { TasksPrimaryButtons } from "./components/tasks-primary-buttons";
import TasksProvider from "./context/tasks-context";
import { tasks } from "./data/tasks";
export default function Tasks() {
return (
<TasksProvider>
<Header fixed>
<Search />
<div className="ml-auto flex items-center space-x-4">
<ThemeSwitch />
<ProfileDropdown />
</div>
</Header>
<Main>
<div className="mb-2 flex flex-wrap items-center justify-between gap-x-4 space-y-2">
<div>
<h2 className="text-2xl font-bold tracking-tight">Tasks</h2>
<p className="text-muted-foreground">
Here&apos;s a list of your tasks for this month!
</p>
</div>
<TasksPrimaryButtons />
</div>
<div className="-mx-4 flex-1 overflow-auto px-4 py-1 lg:flex-row lg:space-x-12 lg:space-y-0">
<DataTable data={tasks} columns={columns} />
</div>
</Main>
<TasksDialogs />
</TasksProvider>
);
}
@@ -0,0 +1,73 @@
import {
ArrowDownIcon,
ArrowUpIcon,
CaretSortIcon,
EyeNoneIcon
} from "@radix-ui/react-icons";
import { Column } from "@tanstack/react-table";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from "@/components/ui/dropdown-menu";
interface DataTableColumnHeaderProps<TData, TValue>
extends React.HTMLAttributes<HTMLDivElement> {
column: Column<TData, TValue>;
title: string;
}
export function DataTableColumnHeader<TData, TValue>({
column,
title,
className
}: DataTableColumnHeaderProps<TData, TValue>) {
if (!column.getCanSort()) {
return <div className={cn(className)}>{title}</div>;
}
return (
<div className={cn("flex items-center space-x-2", className)}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="-ml-3 h-8 data-[state=open]:bg-accent">
<span>{title}</span>
{column.getIsSorted() === "desc" ? (
<ArrowDownIcon className="ml-2 h-4 w-4" />
) : column.getIsSorted() === "asc" ? (
<ArrowUpIcon className="ml-2 h-4 w-4" />
) : (
<CaretSortIcon className="ml-2 h-4 w-4" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem onClick={() => column.toggleSorting(false)}>
<ArrowUpIcon className="mr-2 h-3.5 w-3.5 text-muted-foreground/70" />
Asc
</DropdownMenuItem>
<DropdownMenuItem onClick={() => column.toggleSorting(true)}>
<ArrowDownIcon className="mr-2 h-3.5 w-3.5 text-muted-foreground/70" />
Desc
</DropdownMenuItem>
{column.getCanHide() && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => column.toggleVisibility(false)}>
<EyeNoneIcon className="mr-2 h-3.5 w-3.5 text-muted-foreground/70" />
Hide
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
@@ -0,0 +1,139 @@
import * as React from "react";
import { CheckIcon, PlusCircledIcon } from "@radix-ui/react-icons";
import { Column } from "@tanstack/react-table";
import { cn } from "@/lib/utils";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger
} from "@/components/ui/popover";
import { Separator } from "@/components/ui/separator";
interface DataTableFacetedFilterProps<TData, TValue> {
column?: Column<TData, TValue>;
title?: string;
options: {
label: string;
value: string;
icon?: React.ComponentType<{ className?: string }>;
}[];
}
export function DataTableFacetedFilter<TData, TValue>({
column,
title,
options
}: DataTableFacetedFilterProps<TData, TValue>) {
const facets = column?.getFacetedUniqueValues();
const selectedValues = new Set(column?.getFilterValue() as string[]);
return (
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="h-8 border-dashed">
<PlusCircledIcon className="h-4 w-4" />
{title}
{selectedValues?.size > 0 && (
<>
<Separator orientation="vertical" className="mx-2 h-4" />
<Badge
variant="secondary"
className="rounded-sm px-1 font-normal lg:hidden">
{selectedValues.size}
</Badge>
<div className="hidden space-x-1 lg:flex">
{selectedValues.size > 2 ? (
<Badge
variant="secondary"
className="rounded-sm px-1 font-normal">
{selectedValues.size} selected
</Badge>
) : (
options
.filter((option) => selectedValues.has(option.value))
.map((option) => (
<Badge
variant="secondary"
key={option.value}
className="rounded-sm px-1 font-normal">
{option.label}
</Badge>
))
)}
</div>
</>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0" align="start">
<Command>
<CommandInput placeholder={title} />
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup>
{options.map((option) => {
const isSelected = selectedValues.has(option.value);
return (
<CommandItem
key={option.value}
onSelect={() => {
if (isSelected) {
selectedValues.delete(option.value);
} else {
selectedValues.add(option.value);
}
const filterValues = Array.from(selectedValues);
column?.setFilterValue(
filterValues.length ? filterValues : undefined
);
}}>
<div
className={cn(
"flex h-4 w-4 items-center justify-center rounded-sm border border-primary",
isSelected
? "bg-primary text-primary-foreground"
: "opacity-50 [&_svg]:invisible"
)}>
<CheckIcon className={cn("h-4 w-4")} />
</div>
{option.icon && (
<option.icon className="h-4 w-4 text-muted-foreground" />
)}
<span>{option.label}</span>
{facets?.get(option.value) && (
<span className="ml-auto flex h-4 w-4 items-center justify-center font-mono text-xs">
{facets.get(option.value)}
</span>
)}
</CommandItem>
);
})}
</CommandGroup>
{selectedValues.size > 0 && (
<>
<CommandSeparator />
<CommandGroup>
<CommandItem
onSelect={() => column?.setFilterValue(undefined)}
className="justify-center text-center">
Clear filters
</CommandItem>
</CommandGroup>
</>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
@@ -0,0 +1,93 @@
import {
ChevronLeftIcon,
ChevronRightIcon,
DoubleArrowLeftIcon,
DoubleArrowRightIcon
} from "@radix-ui/react-icons";
import { Table } from "@tanstack/react-table";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@/components/ui/select";
interface DataTablePaginationProps<TData> {
table: Table<TData>;
}
export function DataTablePagination<TData>({
table
}: DataTablePaginationProps<TData>) {
return (
<div
className="flex items-center justify-between overflow-clip px-2"
style={{ overflowClipMargin: 1 }}>
<div className="hidden flex-1 text-sm text-muted-foreground sm:block">
{table.getFilteredSelectedRowModel().rows.length} of{" "}
{table.getFilteredRowModel().rows.length} row(s) selected.
</div>
<div className="flex items-center sm:space-x-6 lg:space-x-8">
<div className="flex items-center space-x-2">
<p className="hidden text-sm font-medium sm:block">Rows per page</p>
<Select
value={`${table.getState().pagination.pageSize}`}
onValueChange={(value) => {
table.setPageSize(Number(value));
}}>
<SelectTrigger className="h-8 w-[70px]">
<SelectValue placeholder={table.getState().pagination.pageSize} />
</SelectTrigger>
<SelectContent side="top">
{[10, 20, 30, 40, 50].map((pageSize) => (
<SelectItem key={pageSize} value={`${pageSize}`}>
{pageSize}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex w-[100px] items-center justify-center text-sm font-medium">
Page {table.getState().pagination.pageIndex + 1} of{" "}
{table.getPageCount()}
</div>
<div className="flex items-center space-x-2">
<Button
variant="outline"
className="hidden h-8 w-8 p-0 lg:flex"
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}>
<span className="sr-only">Go to first page</span>
<DoubleArrowLeftIcon className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="h-8 w-8 p-0"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}>
<span className="sr-only">Go to previous page</span>
<ChevronLeftIcon className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="h-8 w-8 p-0"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}>
<span className="sr-only">Go to next page</span>
<ChevronRightIcon className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="hidden h-8 w-8 p-0 lg:flex"
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}>
<span className="sr-only">Go to last page</span>
<DoubleArrowRightIcon className="h-4 w-4" />
</Button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,60 @@
import { DotsHorizontalIcon } from "@radix-ui/react-icons";
import { Row } from "@tanstack/react-table";
import { IconEdit, IconTrash } from "@tabler/icons-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger
} from "@/components/ui/dropdown-menu";
import { useUsers } from "../context/users-context";
import { User } from "../data/schema";
interface DataTableRowActionsProps {
row: Row<User>;
}
export function DataTableRowActions({ row }: DataTableRowActionsProps) {
const { setOpen, setCurrentRow } = useUsers();
return (
<>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="flex h-8 w-8 p-0 data-[state=open]:bg-muted">
<DotsHorizontalIcon className="h-4 w-4" />
<span className="sr-only">Open menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[160px]">
<DropdownMenuItem
onClick={() => {
setCurrentRow(row.original);
setOpen("edit");
}}>
Edit
<DropdownMenuShortcut>
<IconEdit size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => {
setCurrentRow(row.original);
setOpen("delete");
}}
className="!text-red-500">
Delete
<DropdownMenuShortcut>
<IconTrash size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
);
}
@@ -0,0 +1,65 @@
import { Cross2Icon } from "@radix-ui/react-icons";
import { Table } from "@tanstack/react-table";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { userTypes } from "../data/data";
import { DataTableFacetedFilter } from "./data-table-faceted-filter";
import { DataTableViewOptions } from "./data-table-view-options";
interface DataTableToolbarProps<TData> {
table: Table<TData>;
}
export function DataTableToolbar<TData>({
table
}: DataTableToolbarProps<TData>) {
const isFiltered = table.getState().columnFilters.length > 0;
return (
<div className="flex items-center justify-between">
<div className="flex flex-1 flex-col-reverse items-start gap-y-2 sm:flex-row sm:items-center sm:space-x-2">
<Input
placeholder="Filter users..."
value={
(table.getColumn("username")?.getFilterValue() as string) ?? ""
}
onChange={(event) =>
table.getColumn("username")?.setFilterValue(event.target.value)
}
className="h-8 w-[150px] lg:w-[250px]"
/>
<div className="flex gap-x-2">
{table.getColumn("status") && (
<DataTableFacetedFilter
column={table.getColumn("status")}
title="Status"
options={[
{ label: "Active", value: "active" },
{ label: "Inactive", value: "inactive" },
{ label: "Invited", value: "invited" },
{ label: "Suspended", value: "suspended" }
]}
/>
)}
{table.getColumn("role") && (
<DataTableFacetedFilter
column={table.getColumn("role")}
title="Role"
options={userTypes.map((t) => ({ ...t }))}
/>
)}
</div>
{isFiltered && (
<Button
variant="ghost"
onClick={() => table.resetColumnFilters()}
className="h-8 px-2 lg:px-3">
Reset
<Cross2Icon className="ml-2 h-4 w-4" />
</Button>
)}
</div>
<DataTableViewOptions table={table} />
</div>
);
}
@@ -0,0 +1,54 @@
import { DropdownMenuTrigger } from "@radix-ui/react-dropdown-menu";
import { MixerHorizontalIcon } from "@radix-ui/react-icons";
import { Table } from "@tanstack/react-table";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuSeparator
} from "@/components/ui/dropdown-menu";
interface DataTableViewOptionsProps<TData> {
table: Table<TData>;
}
export function DataTableViewOptions<TData>({
table
}: DataTableViewOptionsProps<TData>) {
return (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="ml-auto hidden h-8 lg:flex">
<MixerHorizontalIcon className="mr-2 h-4 w-4" />
View
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[150px]">
<DropdownMenuLabel>Toggle columns</DropdownMenuLabel>
<DropdownMenuSeparator />
{table
.getAllColumns()
.filter(
(column) =>
typeof column.accessorFn !== "undefined" && column.getCanHide()
)
.map((column) => {
return (
<DropdownMenuCheckboxItem
key={column.id}
className="capitalize"
checked={column.getIsVisible()}
onCheckedChange={(value) => column.toggleVisibility(!!value)}>
{column.id}
</DropdownMenuCheckboxItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -0,0 +1,326 @@
"use client";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { toast } from "@/hooks/use-toast";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { PasswordInput } from "@/components/password-input";
import { SelectDropdown } from "@/components/select-dropdown";
import { userTypes } from "../data/data";
import { User } from "../data/schema";
const formSchema = z
.object({
firstName: z.string().min(1, { message: "First Name is required." }),
lastName: z.string().min(1, { message: "Last Name is required." }),
username: z.string().min(1, { message: "Username is required." }),
phoneNumber: z.string().min(1, { message: "Phone number is required." }),
email: z
.string()
.min(1, { message: "Email is required." })
.email({ message: "Email is invalid." }),
password: z.string().transform((pwd) => pwd.trim()),
role: z.string().min(1, { message: "Role is required." }),
confirmPassword: z.string().transform((pwd) => pwd.trim()),
isEdit: z.boolean()
})
.superRefine(({ isEdit, password, confirmPassword }, ctx) => {
if (!isEdit || (isEdit && password !== "")) {
if (password === "") {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Password is required.",
path: ["password"]
});
}
if (password.length < 8) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Password must be at least 8 characters long.",
path: ["password"]
});
}
if (!password.match(/[a-z]/)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Password must contain at least one lowercase letter.",
path: ["password"]
});
}
if (!password.match(/\d/)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Password must contain at least one number.",
path: ["password"]
});
}
if (password !== confirmPassword) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Passwords don't match.",
path: ["confirmPassword"]
});
}
}
});
type UserForm = z.infer<typeof formSchema>;
interface Props {
currentRow?: User;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function UsersActionDialog({ currentRow, open, onOpenChange }: Props) {
const isEdit = !!currentRow;
const form = useForm<UserForm>({
resolver: zodResolver(formSchema),
defaultValues: isEdit
? {
...currentRow,
password: "",
confirmPassword: "",
isEdit
}
: {
firstName: "",
lastName: "",
username: "",
email: "",
role: "",
phoneNumber: "",
password: "",
confirmPassword: "",
isEdit
}
});
const onSubmit = (values: UserForm) => {
form.reset();
toast({
title: "You submitted the following values:",
description: (
<pre className="mt-2 w-[340px] rounded-md bg-slate-950 p-4">
<code className="text-white">{JSON.stringify(values, null, 2)}</code>
</pre>
)
});
onOpenChange(false);
};
const isPasswordTouched = !!form.formState.dirtyFields.password;
return (
<Dialog
open={open}
onOpenChange={(state) => {
form.reset();
onOpenChange(state);
}}>
<DialogContent className="sm:max-w-lg">
<DialogHeader className="text-left">
<DialogTitle>{isEdit ? "Edit User" : "Add New User"}</DialogTitle>
<DialogDescription>
{isEdit ? "Update the user here. " : "Create new user here. "}
Click save when you&apos;re done.
</DialogDescription>
</DialogHeader>
<ScrollArea className="-mr-4 h-[26.25rem] w-full py-1 pr-4">
<Form {...form}>
<form
id="user-form"
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4 p-0.5">
<FormField
control={form.control}
name="firstName"
render={({ field }) => (
<FormItem className="grid grid-cols-6 items-center gap-x-4 gap-y-1 space-y-0">
<FormLabel className="col-span-2 text-right">
First Name
</FormLabel>
<FormControl>
<Input
placeholder="John"
className="col-span-4"
autoComplete="off"
{...field}
/>
</FormControl>
<FormMessage className="col-span-4 col-start-3" />
</FormItem>
)}
/>
<FormField
control={form.control}
name="lastName"
render={({ field }) => (
<FormItem className="grid grid-cols-6 items-center gap-x-4 gap-y-1 space-y-0">
<FormLabel className="col-span-2 text-right">
Last Name
</FormLabel>
<FormControl>
<Input
placeholder="Doe"
className="col-span-4"
autoComplete="off"
{...field}
/>
</FormControl>
<FormMessage className="col-span-4 col-start-3" />
</FormItem>
)}
/>
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem className="grid grid-cols-6 items-center gap-x-4 gap-y-1 space-y-0">
<FormLabel className="col-span-2 text-right">
Username
</FormLabel>
<FormControl>
<Input
placeholder="john_doe"
className="col-span-4"
{...field}
/>
</FormControl>
<FormMessage className="col-span-4 col-start-3" />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem className="grid grid-cols-6 items-center gap-x-4 gap-y-1 space-y-0">
<FormLabel className="col-span-2 text-right">
Email
</FormLabel>
<FormControl>
<Input
placeholder="john.doe@gmail.com"
className="col-span-4"
{...field}
/>
</FormControl>
<FormMessage className="col-span-4 col-start-3" />
</FormItem>
)}
/>
<FormField
control={form.control}
name="phoneNumber"
render={({ field }) => (
<FormItem className="grid grid-cols-6 items-center gap-x-4 gap-y-1 space-y-0">
<FormLabel className="col-span-2 text-right">
Phone Number
</FormLabel>
<FormControl>
<Input
placeholder="+123456789"
className="col-span-4"
{...field}
/>
</FormControl>
<FormMessage className="col-span-4 col-start-3" />
</FormItem>
)}
/>
<FormField
control={form.control}
name="role"
render={({ field }) => (
<FormItem className="grid grid-cols-6 items-center gap-x-4 gap-y-1 space-y-0">
<FormLabel className="col-span-2 text-right">
Role
</FormLabel>
<SelectDropdown
defaultValue={field.value}
onValueChange={field.onChange}
placeholder="Select a role"
className="col-span-4"
items={userTypes.map(({ label, value }) => ({
label,
value
}))}
/>
<FormMessage className="col-span-4 col-start-3" />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem className="grid grid-cols-6 items-center gap-x-4 gap-y-1 space-y-0">
<FormLabel className="col-span-2 text-right">
Password
</FormLabel>
<FormControl>
<PasswordInput
placeholder="e.g., S3cur3P@ssw0rd"
className="col-span-4"
{...field}
/>
</FormControl>
<FormMessage className="col-span-4 col-start-3" />
</FormItem>
)}
/>
<FormField
control={form.control}
name="confirmPassword"
render={({ field }) => (
<FormItem className="grid grid-cols-6 items-center gap-x-4 gap-y-1 space-y-0">
<FormLabel className="col-span-2 text-right">
Confirm Password
</FormLabel>
<FormControl>
<PasswordInput
disabled={!isPasswordTouched}
placeholder="e.g., S3cur3P@ssw0rd"
className="col-span-4"
{...field}
/>
</FormControl>
<FormMessage className="col-span-4 col-start-3" />
</FormItem>
)}
/>
</form>
</Form>
</ScrollArea>
<DialogFooter>
<Button type="submit" form="user-form">
Save changes
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,142 @@
import { ColumnDef } from "@tanstack/react-table";
import { cn } from "@/lib/utils";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import LongText from "@/components/long-text";
import { callTypes, userTypes } from "../data/data";
import { User } from "../data/schema";
import { DataTableColumnHeader } from "./data-table-column-header";
import { DataTableRowActions } from "./data-table-row-actions";
export const columns: ColumnDef<User>[] = [
{
id: "select",
header: ({ table }) => (
<Checkbox
checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && "indeterminate")
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
className="translate-y-[2px]"
/>
),
meta: {
className: cn(
"sticky md:table-cell left-0 z-10 rounded-tl",
"bg-background transition-colors duration-200 group-hover/row:bg-muted group-data-[state=selected]/row:bg-muted"
)
},
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
className="translate-y-[2px]"
/>
),
enableSorting: false,
enableHiding: false
},
{
accessorKey: "username",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Username" />
),
cell: ({ row }) => (
<LongText className="max-w-36">{row.getValue("username")}</LongText>
),
meta: {
className: cn(
"drop-shadow-[0_1px_2px_rgb(0_0_0_/_0.1)] dark:drop-shadow-[0_1px_2px_rgb(255_255_255_/_0.1)] lg:drop-shadow-none",
"bg-background transition-colors duration-200 group-hover/row:bg-muted group-data-[state=selected]/row:bg-muted",
"sticky left-6 md:table-cell"
)
},
enableHiding: false
},
{
id: "fullName",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Name" />
),
cell: ({ row }) => {
const { firstName, lastName } = row.original;
const fullName = `${firstName} ${lastName}`;
return <LongText className="max-w-36">{fullName}</LongText>;
},
meta: { className: "w-36" }
},
{
accessorKey: "email",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Email" />
),
cell: ({ row }) => (
<div className="w-fit text-nowrap">{row.getValue("email")}</div>
)
},
{
accessorKey: "phoneNumber",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Phone Number" />
),
cell: ({ row }) => <div>{row.getValue("phoneNumber")}</div>,
enableSorting: false
},
{
accessorKey: "status",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Status" />
),
cell: ({ row }) => {
const { status } = row.original;
const badgeColor = callTypes.get(status);
return (
<div className="flex space-x-2">
<Badge variant="outline" className={cn("capitalize", badgeColor)}>
{row.getValue("status")}
</Badge>
</div>
);
},
filterFn: (row, id, value) => {
return value.includes(row.getValue(id));
},
enableHiding: false,
enableSorting: false
},
{
accessorKey: "role",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Role" />
),
cell: ({ row }) => {
const { role } = row.original;
const userType = userTypes.find(({ value }) => value === role);
if (!userType) {
return null;
}
return (
<div className="flex items-center gap-x-2">
{userType.icon && (
<userType.icon size={16} className="text-muted-foreground" />
)}
<span className="text-sm capitalize">{row.getValue("role")}</span>
</div>
);
},
filterFn: (row, id, value) => {
return value.includes(row.getValue(id));
},
enableSorting: false,
enableHiding: false
},
{
id: "actions",
cell: DataTableRowActions
}
];
@@ -0,0 +1,86 @@
"use client";
import { useState } from "react";
import { IconAlertTriangle } from "@tabler/icons-react";
import { toast } from "@/hooks/use-toast";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ConfirmDialog } from "@/components/confirm-dialog";
import { User } from "../data/schema";
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
currentRow: User;
}
export function UsersDeleteDialog({ open, onOpenChange, currentRow }: Props) {
const [value, setValue] = useState("");
const handleDelete = () => {
if (value.trim() !== currentRow.username) return;
onOpenChange(false);
toast({
title: "The following user has been deleted:",
description: (
<pre className="mt-2 w-[340px] rounded-md bg-slate-950 p-4">
<code className="text-white">
{JSON.stringify(currentRow, null, 2)}
</code>
</pre>
)
});
};
return (
<ConfirmDialog
open={open}
onOpenChange={onOpenChange}
handleConfirm={handleDelete}
disabled={value.trim() !== currentRow.username}
title={
<span className="text-destructive">
<IconAlertTriangle
className="mr-1 inline-block stroke-destructive"
size={18}
/>{" "}
Delete User
</span>
}
desc={
<div className="space-y-4">
<p className="mb-2">
Are you sure you want to delete{" "}
<span className="font-bold">{currentRow.username}</span>?
<br />
This action will permanently remove the user with the role of{" "}
<span className="font-bold">
{currentRow.role.toUpperCase()}
</span>{" "}
from the system. This cannot be undone.
</p>
<Label className="my-2">
Username:
<Input
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="Enter username to confirm deletion."
/>
</Label>
<Alert variant="destructive">
<AlertTitle>Warning!</AlertTitle>
<AlertDescription>
Please be carefull, this operation can not be rolled back.
</AlertDescription>
</Alert>
</div>
}
confirmText="Delete"
destructive
/>
);
}
@@ -0,0 +1,51 @@
import { useUsers } from "../context/users-context";
import { UsersActionDialog } from "./users-action-dialog";
import { UsersDeleteDialog } from "./users-delete-dialog";
import { UsersInviteDialog } from "./users-invite-dialog";
export function UsersDialogs() {
const { open, setOpen, currentRow, setCurrentRow } = useUsers();
return (
<>
<UsersActionDialog
key="user-add"
open={open === "add"}
onOpenChange={() => setOpen("add")}
/>
<UsersInviteDialog
key="user-invite"
open={open === "invite"}
onOpenChange={() => setOpen("invite")}
/>
{currentRow && (
<>
<UsersActionDialog
key={`user-edit-${currentRow.id}`}
open={open === "edit"}
onOpenChange={() => {
setOpen("edit");
setTimeout(() => {
setCurrentRow(null);
}, 500);
}}
currentRow={currentRow}
/>
<UsersDeleteDialog
key={`user-delete-${currentRow.id}`}
open={open === "delete"}
onOpenChange={() => {
setOpen("delete");
setTimeout(() => {
setCurrentRow(null);
}, 500);
}}
currentRow={currentRow}
/>
</>
)}
</>
);
}
@@ -0,0 +1,151 @@
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { IconMailPlus, IconSend } from "@tabler/icons-react";
import { toast } from "@/hooks/use-toast";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { SelectDropdown } from "@/components/select-dropdown";
import { userTypes } from "../data/data";
const formSchema = z.object({
email: z
.string()
.min(1, { message: "Email is required." })
.email({ message: "Email is invalid." }),
role: z.string().min(1, { message: "Role is required." }),
desc: z.string().optional()
});
type UserInviteForm = z.infer<typeof formSchema>;
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function UsersInviteDialog({ open, onOpenChange }: Props) {
const form = useForm<UserInviteForm>({
resolver: zodResolver(formSchema),
defaultValues: { email: "", role: "", desc: "" }
});
const onSubmit = (values: UserInviteForm) => {
form.reset();
toast({
title: "You submitted the following values:",
description: (
<pre className="mt-2 w-[340px] rounded-md bg-slate-950 p-4">
<code className="text-white">{JSON.stringify(values, null, 2)}</code>
</pre>
)
});
onOpenChange(false);
};
return (
<Dialog
open={open}
onOpenChange={(state) => {
form.reset();
onOpenChange(state);
}}>
<DialogContent className="sm:max-w-md">
<DialogHeader className="text-left">
<DialogTitle className="flex items-center gap-2">
<IconMailPlus /> Invite User
</DialogTitle>
<DialogDescription>
Invite new user to join your team by sending them an email
invitation. Assign a role to define their access level.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form
id="user-invite-form"
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input
type="email"
placeholder="eg: john.doe@gmail.com"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="role"
render={({ field }) => (
<FormItem className="space-y-1">
<FormLabel>Role</FormLabel>
<SelectDropdown
defaultValue={field.value}
onValueChange={field.onChange}
placeholder="Select a role"
items={userTypes.map(({ label, value }) => ({
label,
value
}))}
/>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="desc"
render={({ field }) => (
<FormItem className="">
<FormLabel>Description (optional)</FormLabel>
<FormControl>
<Textarea
className="resize-none"
placeholder="Add a personal note to your invitation (optional)"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
<DialogFooter className="gap-y-2">
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<Button type="submit" form="user-invite-form">
Invite <IconSend />
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,20 @@
import { IconMailPlus, IconUserPlus } from "@tabler/icons-react";
import { Button } from "@/components/ui/button";
import { useUsers } from "../context/users-context";
export function UsersPrimaryButtons() {
const { setOpen } = useUsers();
return (
<div className="flex gap-2">
<Button
variant="outline"
className="space-x-1"
onClick={() => setOpen("invite")}>
<span>Invite User</span> <IconMailPlus size={18} />
</Button>
<Button className="space-x-1" onClick={() => setOpen("add")}>
<span>Add User</span> <IconUserPlus size={18} />
</Button>
</div>
);
}
@@ -0,0 +1,129 @@
import { useState } from "react";
import {
ColumnDef,
ColumnFiltersState,
RowData,
SortingState,
VisibilityState,
flexRender,
getCoreRowModel,
getFacetedRowModel,
getFacetedUniqueValues,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable
} from "@tanstack/react-table";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from "@/components/ui/table";
import { User } from "../data/schema";
import { DataTablePagination } from "./data-table-pagination";
import { DataTableToolbar } from "./data-table-toolbar";
declare module "@tanstack/react-table" {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
interface ColumnMeta<TData extends RowData, TValue> {
className: string;
}
}
interface DataTableProps {
columns: ColumnDef<User>[];
data: User[];
}
export function UsersTable({ columns, data }: DataTableProps) {
const [rowSelection, setRowSelection] = useState({});
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [sorting, setSorting] = useState<SortingState>([]);
const table = useReactTable({
data,
columns,
state: {
sorting,
columnVisibility,
rowSelection,
columnFilters
},
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
onColumnVisibilityChange: setColumnVisibility,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFacetedRowModel: getFacetedRowModel(),
getFacetedUniqueValues: getFacetedUniqueValues()
});
return (
<div className="space-y-4">
<DataTableToolbar table={table} />
<div className="rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id} className="group/row">
{headerGroup.headers.map((header) => {
return (
<TableHead
key={header.id}
colSpan={header.colSpan}
className={header.column.columnDef.meta?.className ?? ""}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
className="group/row">
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
className={cell.column.columnDef.meta?.className ?? ""}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center">
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<DataTablePagination table={table} />
</div>
);
}
@@ -0,0 +1,40 @@
import React, { useState } from "react";
import useDialogState from "@/hooks/use-dialog-state";
import { User } from "../data/schema";
type UsersDialogType = "invite" | "add" | "edit" | "delete";
interface UsersContextType {
open: UsersDialogType | null;
setOpen: (str: UsersDialogType | null) => void;
currentRow: User | null;
setCurrentRow: React.Dispatch<React.SetStateAction<User | null>>;
}
const UsersContext = React.createContext<UsersContextType | null>(null);
interface Props {
children: React.ReactNode;
}
export default function UsersProvider({ children }: Props) {
const [open, setOpen] = useDialogState<UsersDialogType>(null);
const [currentRow, setCurrentRow] = useState<User | null>(null);
return (
<UsersContext value={{ open, setOpen, currentRow, setCurrentRow }}>
{children}
</UsersContext>
);
}
// eslint-disable-next-line react-refresh/only-export-components
export const useUsers = () => {
const usersContext = React.useContext(UsersContext);
if (!usersContext) {
throw new Error("useUsers has to be used within <UsersContext>");
}
return usersContext;
};
+40
View File
@@ -0,0 +1,40 @@
import {
IconCash,
IconShield,
IconUsersGroup,
IconUserShield
} from "@tabler/icons-react";
import { UserStatus } from "./schema";
export const callTypes = new Map<UserStatus, string>([
["active", "bg-teal-100/30 text-teal-900 dark:text-teal-200 border-teal-200"],
["inactive", "bg-neutral-300/40 border-neutral-300"],
["invited", "bg-sky-200/40 text-sky-900 dark:text-sky-100 border-sky-300"],
[
"suspended",
"bg-destructive/10 dark:bg-destructive/50 text-destructive dark:text-primary border-destructive/10"
]
]);
export const userTypes = [
{
label: "Superadmin",
value: "superadmin",
icon: IconShield
},
{
label: "Admin",
value: "admin",
icon: IconUserShield
},
{
label: "Manager",
value: "manager",
icon: IconUsersGroup
},
{
label: "Cashier",
value: "cashier",
icon: IconCash
}
] as const;
+32
View File
@@ -0,0 +1,32 @@
import { z } from "zod";
const userStatusSchema = z.union([
z.literal("active"),
z.literal("inactive"),
z.literal("invited"),
z.literal("suspended")
]);
export type UserStatus = z.infer<typeof userStatusSchema>;
const userRoleSchema = z.union([
z.literal("superadmin"),
z.literal("admin"),
z.literal("cashier"),
z.literal("manager")
]);
const userSchema = z.object({
id: z.string(),
firstName: z.string(),
lastName: z.string(),
username: z.string(),
email: z.string(),
phoneNumber: z.string(),
status: userStatusSchema,
role: userRoleSchema,
createdAt: z.coerce.date(),
updatedAt: z.coerce.date()
});
export type User = z.infer<typeof userSchema>;
export const userListSchema = z.array(userSchema);
+30
View File
@@ -0,0 +1,30 @@
import { faker } from "@faker-js/faker";
export const users = Array.from({ length: 20 }, () => {
const firstName = faker.person.firstName();
const lastName = faker.person.lastName();
return {
id: faker.string.uuid(),
firstName,
lastName,
username: faker.internet
.username({ firstName, lastName })
.toLocaleLowerCase(),
email: faker.internet.email({ firstName }).toLocaleLowerCase(),
phoneNumber: faker.phone.number({ style: "international" }),
status: faker.helpers.arrayElement([
"active",
"inactive",
"invited",
"suspended"
]),
role: faker.helpers.arrayElement([
"superadmin",
"admin",
"cashier",
"manager"
]),
createdAt: faker.date.past(),
updatedAt: faker.date.recent()
};
});
+46
View File
@@ -0,0 +1,46 @@
import { Header } from "@/components/layout/header";
import { Main } from "@/components/layout/main";
import { ProfileDropdown } from "@/components/profile-dropdown";
import { Search } from "@/components/search";
import { ThemeSwitch } from "@/components/theme-switch";
import { columns } from "./components/users-columns";
import { UsersDialogs } from "./components/users-dialogs";
import { UsersPrimaryButtons } from "./components/users-primary-buttons";
import { UsersTable } from "./components/users-table";
import UsersProvider from "./context/users-context";
import { userListSchema } from "./data/schema";
import { users } from "./data/users";
export default function Users() {
// Parse user list
const userList = userListSchema.parse(users);
return (
<UsersProvider>
<Header fixed>
<Search />
<div className="ml-auto flex items-center space-x-4">
<ThemeSwitch />
<ProfileDropdown />
</div>
</Header>
<Main>
<div className="mb-2 flex flex-wrap items-center justify-between space-y-2">
<div>
<h2 className="text-2xl font-bold tracking-tight">User List</h2>
<p className="text-muted-foreground">
Manage your users and their roles here.
</p>
</div>
<UsersPrimaryButtons />
</div>
<div className="-mx-4 flex-1 overflow-auto px-4 py-1 lg:flex-row lg:space-x-12 lg:space-y-0">
<UsersTable data={userList} columns={columns} />
</div>
</Main>
<UsersDialogs />
</UsersProvider>
);
}