mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-08-23 23:02:17 +10:00
v5.2.0: undo/redo, version history, embedded AI assistant, mobile builder & more (#3205)
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import type { Icon as IconType } from "@phosphor-icons/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { SidebarTrigger } from "@reactive-resume/ui/components/sidebar";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
|
||||
@@ -6,14 +7,18 @@ type Props = {
|
||||
title: string;
|
||||
icon: IconType;
|
||||
className?: string;
|
||||
actions?: ReactNode;
|
||||
};
|
||||
|
||||
export function DashboardHeader({ title, icon: IconComponent, className }: Props) {
|
||||
export function DashboardHeader({ title, icon: IconComponent, className, actions }: Props) {
|
||||
return (
|
||||
<div className={cn("relative flex items-center justify-center gap-x-2.5 md:justify-start", className)}>
|
||||
<div className={cn("relative flex items-center gap-x-2.5", className)}>
|
||||
<SidebarTrigger className="absolute inset-s-0 md:hidden" />
|
||||
<IconComponent weight="light" className="size-5" />
|
||||
<h1 className="font-medium text-xl tracking-tight">{title}</h1>
|
||||
<div className="flex flex-1 items-center justify-center gap-x-2.5 md:justify-start">
|
||||
<IconComponent weight="light" className="size-5" />
|
||||
<h1 className="font-medium text-xl tracking-tight">{title}</h1>
|
||||
</div>
|
||||
{actions ? <div className="flex items-center gap-x-2">{actions}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
ChatCircleDotsIcon,
|
||||
GearSixIcon,
|
||||
KeyIcon,
|
||||
MagnifyingGlassIcon,
|
||||
ReadCvLogoIcon,
|
||||
ShieldCheckIcon,
|
||||
UserCircleIcon,
|
||||
@@ -16,6 +17,7 @@ import { Link } from "@tanstack/react-router";
|
||||
import { AnimatePresence, m } from "motion/react";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@reactive-resume/ui/components/avatar";
|
||||
import { BrandIcon } from "@reactive-resume/ui/components/brand-icon";
|
||||
import { Kbd } from "@reactive-resume/ui/components/kbd";
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
@@ -33,6 +35,7 @@ import {
|
||||
} from "@reactive-resume/ui/components/sidebar";
|
||||
import { getInitials } from "@reactive-resume/utils/string";
|
||||
import { Copyright } from "@/components/ui/copyright";
|
||||
import { useCommandPaletteStore } from "@/features/command-palette/store";
|
||||
import { UserDropdownMenu } from "@/features/user/dropdown-menu";
|
||||
|
||||
type SidebarItem = {
|
||||
@@ -115,7 +118,27 @@ function SidebarItemList({ items }: SidebarItemListProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarSearchButton() {
|
||||
const { i18n } = useLingui();
|
||||
const setOpen = useCommandPaletteStore((state) => state.setOpen);
|
||||
|
||||
const label = i18n.t(msg`Search`);
|
||||
|
||||
return (
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton title={label} tooltip={label} onClick={() => setOpen(true)}>
|
||||
<MagnifyingGlassIcon />
|
||||
<span className="flex-1 text-start transition-[margin,opacity] duration-200 ease-in-out group-data-[collapsible=icon]:-ms-8 group-data-[collapsible=icon]:opacity-0">
|
||||
{label}
|
||||
</span>
|
||||
<Kbd className="transition-opacity duration-200 ease-in-out group-data-[collapsible=icon]:opacity-0">⌘K</Kbd>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
export function DashboardSidebar() {
|
||||
const { i18n } = useLingui();
|
||||
const { state } = useSidebarState();
|
||||
|
||||
return (
|
||||
@@ -133,12 +156,14 @@ export function DashboardSidebar() {
|
||||
}
|
||||
/>
|
||||
</SidebarMenuItem>
|
||||
|
||||
<SidebarSearchButton />
|
||||
</SidebarMenu>
|
||||
</SidebarHeader>
|
||||
|
||||
<SidebarSeparator />
|
||||
|
||||
<SidebarContent>
|
||||
<SidebarContent aria-label={i18n.t(msg`Dashboard`)} role="navigation">
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>
|
||||
<Trans>App</Trans>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { RouterOutput } from "@/libs/orpc/client";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { AnimatePresence, m } from "motion/react";
|
||||
import { CreateResumeCard } from "./cards/create-card";
|
||||
import { ImportResumeCard } from "./cards/import-card";
|
||||
@@ -8,44 +9,59 @@ type Resume = RouterOutput["resume"]["list"][number];
|
||||
|
||||
type Props = {
|
||||
resumes: Resume[];
|
||||
hasResumes: boolean;
|
||||
};
|
||||
|
||||
export function GridView({ resumes }: Props) {
|
||||
export function GridView({ resumes, hasResumes }: Props) {
|
||||
if (resumes.length === 0 && hasResumes) {
|
||||
return (
|
||||
<p className="py-8 text-center text-muted-foreground text-sm">
|
||||
<Trans>No resumes match your search.</Trans>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (resumes.length === 0) {
|
||||
return (
|
||||
<div className="grid 3xl:grid-cols-6 grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5">
|
||||
<m.div
|
||||
initial={{ y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ y: -20 }}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
className="will-change-[transform,opacity]"
|
||||
>
|
||||
<CreateResumeCard />
|
||||
</m.div>
|
||||
|
||||
<m.div
|
||||
initial={{ y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ y: -20 }}
|
||||
transition={{ duration: 0.2, delay: 0.03, ease: "easeOut" }}
|
||||
className="will-change-[transform,opacity]"
|
||||
>
|
||||
<ImportResumeCard />
|
||||
</m.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid 3xl:grid-cols-6 grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5">
|
||||
<m.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
className="will-change-[transform,opacity]"
|
||||
>
|
||||
<CreateResumeCard />
|
||||
</m.div>
|
||||
|
||||
<m.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.2, delay: 0.03, ease: "easeOut" }}
|
||||
className="will-change-[transform,opacity]"
|
||||
>
|
||||
<ImportResumeCard />
|
||||
</m.div>
|
||||
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
{resumes?.map((resume, index) => (
|
||||
{resumes.map((resume, index) => (
|
||||
<m.div
|
||||
layout
|
||||
key={resume.id}
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
initial={{ y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
y: -20,
|
||||
filter: "blur(8px)",
|
||||
}}
|
||||
transition={{ duration: 0.2, delay: Math.min(0.12, (index + 2) * 0.02), ease: "easeOut" }}
|
||||
transition={{ duration: 0.2, delay: Math.min(0.12, index * 0.02), ease: "easeOut" }}
|
||||
className="will-change-[transform,opacity]"
|
||||
>
|
||||
<ResumeCard resume={resume} />
|
||||
|
||||
@@ -13,76 +13,91 @@ type Resume = RouterOutput["resume"]["list"][number];
|
||||
|
||||
type ListViewProps = {
|
||||
resumes: Resume[];
|
||||
hasResumes: boolean;
|
||||
};
|
||||
|
||||
type ResumeListItemProps = {
|
||||
resume: Resume;
|
||||
};
|
||||
|
||||
export function ListView({ resumes }: ListViewProps) {
|
||||
export function ListView({ resumes, hasResumes }: ListViewProps) {
|
||||
const { openDialog } = useDialogStore();
|
||||
|
||||
const handleCreateResume = () => {
|
||||
openDialog("resume.create", undefined);
|
||||
};
|
||||
if (resumes.length === 0 && hasResumes) {
|
||||
return (
|
||||
<p className="py-8 text-center text-muted-foreground text-sm">
|
||||
<Trans>No resumes match your search.</Trans>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const handleImportResume = () => {
|
||||
openDialog("resume.import", undefined);
|
||||
};
|
||||
if (resumes.length === 0) {
|
||||
const handleCreateResume = () => {
|
||||
openDialog("resume.create", undefined);
|
||||
};
|
||||
|
||||
const handleImportResume = () => {
|
||||
openDialog("resume.import", undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-y-1">
|
||||
<m.div
|
||||
className="will-change-[transform,opacity]"
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
>
|
||||
<Button
|
||||
size="lg"
|
||||
variant="ghost"
|
||||
className="h-12 w-full justify-start gap-x-4 text-start"
|
||||
onClick={handleCreateResume}
|
||||
>
|
||||
<PlusIcon />
|
||||
<div className="min-w-80 truncate">
|
||||
<Trans>Create a new resume</Trans>
|
||||
</div>
|
||||
|
||||
<p className="text-xs opacity-60">
|
||||
<Trans>Start building your resume from scratch</Trans>
|
||||
</p>
|
||||
</Button>
|
||||
</m.div>
|
||||
|
||||
<m.div
|
||||
className="will-change-[transform,opacity]"
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.2, delay: 0.03, ease: "easeOut" }}
|
||||
>
|
||||
<Button
|
||||
size="lg"
|
||||
variant="ghost"
|
||||
className="h-12 w-full justify-start gap-x-4 text-start"
|
||||
onClick={handleImportResume}
|
||||
>
|
||||
<DownloadSimpleIcon />
|
||||
|
||||
<div className="min-w-80 truncate">
|
||||
<Trans>Import an existing resume</Trans>
|
||||
</div>
|
||||
|
||||
<p className="text-xs opacity-60">
|
||||
<Trans>Continue where you left off</Trans>
|
||||
</p>
|
||||
</Button>
|
||||
</m.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-y-1">
|
||||
<m.div
|
||||
className="will-change-[transform,opacity]"
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
>
|
||||
<Button
|
||||
size="lg"
|
||||
variant="ghost"
|
||||
className="h-12 w-full justify-start gap-x-4 text-start"
|
||||
onClick={handleCreateResume}
|
||||
>
|
||||
<PlusIcon />
|
||||
<div className="min-w-80 truncate">
|
||||
<Trans>Create a new resume</Trans>
|
||||
</div>
|
||||
|
||||
<p className="text-xs opacity-60">
|
||||
<Trans>Start building your resume from scratch</Trans>
|
||||
</p>
|
||||
</Button>
|
||||
</m.div>
|
||||
|
||||
<m.div
|
||||
className="will-change-[transform,opacity]"
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.2, delay: 0.03, ease: "easeOut" }}
|
||||
>
|
||||
<Button
|
||||
size="lg"
|
||||
variant="ghost"
|
||||
className="h-12 w-full justify-start gap-x-4 text-start"
|
||||
onClick={handleImportResume}
|
||||
>
|
||||
<DownloadSimpleIcon />
|
||||
|
||||
<div className="min-w-80 truncate">
|
||||
<Trans>Import an existing resume</Trans>
|
||||
</div>
|
||||
|
||||
<p className="text-xs opacity-60">
|
||||
<Trans>Continue where you left off</Trans>
|
||||
</p>
|
||||
</Button>
|
||||
</m.div>
|
||||
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
{resumes?.map((resume, index) => (
|
||||
{resumes.map((resume, index) => (
|
||||
<m.div
|
||||
layout
|
||||
key={resume.id}
|
||||
@@ -90,7 +105,7 @@ export function ListView({ resumes }: ListViewProps) {
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.18, delay: Math.min(0.12, (index + 2) * 0.02), ease: "easeOut" }}
|
||||
transition={{ duration: 0.18, delay: Math.min(0.12, index * 0.02), ease: "easeOut" }}
|
||||
>
|
||||
<ResumeListItem resume={resume} />
|
||||
</m.div>
|
||||
|
||||
@@ -103,7 +103,7 @@ export function ResumeContextMenu({ resume, children }: Props) {
|
||||
|
||||
<ContextMenuItem disabled={resume.isLocked} onClick={handleUpdate}>
|
||||
<PencilSimpleLineIcon />
|
||||
<Trans comment="Resume card context menu action to edit resume metadata">Update</Trans>
|
||||
<Trans comment="Resume card context menu action to edit resume metadata">Edit details</Trans>
|
||||
</ContextMenuItem>
|
||||
|
||||
<ContextMenuItem onClick={handleDuplicate}>
|
||||
|
||||
@@ -101,7 +101,7 @@ export function ResumeDropdownMenu({ resume, children, ...props }: Props) {
|
||||
|
||||
<DropdownMenuItem disabled={resume.isLocked} onClick={handleUpdate}>
|
||||
<PencilSimpleLineIcon />
|
||||
<Trans comment="Resume card dropdown action to edit resume metadata">Update</Trans>
|
||||
<Trans comment="Resume card dropdown action to edit resume metadata">Edit details</Trans>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem onClick={handleDuplicate}>
|
||||
|
||||
@@ -1,16 +1,26 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { msg, t } from "@lingui/core/macro";
|
||||
import { useLingui } from "@lingui/react";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { GridFourIcon, ListIcon, ReadCvLogoIcon } from "@phosphor-icons/react";
|
||||
import {
|
||||
DownloadSimpleIcon,
|
||||
GridFourIcon,
|
||||
ListIcon,
|
||||
MagnifyingGlassIcon,
|
||||
PlusIcon,
|
||||
ReadCvLogoIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { createFileRoute, Link, stripSearchParams, useNavigate } from "@tanstack/react-router";
|
||||
import { useMemo } from "react";
|
||||
import z from "zod";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { InputGroup, InputGroupAddon, InputGroupInput } from "@reactive-resume/ui/components/input-group";
|
||||
import { Label } from "@reactive-resume/ui/components/label";
|
||||
import { Separator } from "@reactive-resume/ui/components/separator";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@reactive-resume/ui/components/tabs";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { orpc } from "@/libs/orpc/client";
|
||||
import { DashboardHeader } from "../-components/header";
|
||||
import { GridView } from "./-components/grid-view";
|
||||
@@ -19,6 +29,7 @@ import { ListView } from "./-components/list-view";
|
||||
type SortOption = "lastUpdatedAt" | "createdAt" | "name";
|
||||
|
||||
const searchSchema = z.object({
|
||||
search: z.string().default(""),
|
||||
tags: z.array(z.string()).default([]),
|
||||
sort: z.enum(["lastUpdatedAt", "createdAt", "name"]).default("lastUpdatedAt"),
|
||||
view: z.enum(["grid", "list"]).default("grid"),
|
||||
@@ -26,7 +37,7 @@ const searchSchema = z.object({
|
||||
|
||||
type Search = z.output<typeof searchSchema>;
|
||||
|
||||
const defaultSearch: Search = { tags: [], sort: "lastUpdatedAt", view: "grid" };
|
||||
const defaultSearch: Search = { search: "", tags: [], sort: "lastUpdatedAt", view: "grid" };
|
||||
|
||||
export const Route = createFileRoute("/dashboard/resumes/")({
|
||||
component: RouteComponent,
|
||||
@@ -38,12 +49,22 @@ export const Route = createFileRoute("/dashboard/resumes/")({
|
||||
|
||||
function RouteComponent() {
|
||||
const { i18n } = useLingui();
|
||||
const { tags, sort, view } = Route.useSearch();
|
||||
const { search, tags, sort, view } = Route.useSearch();
|
||||
const navigate = useNavigate({ from: Route.fullPath });
|
||||
const { openDialog } = useDialogStore();
|
||||
|
||||
const { data: allTags } = useQuery(orpc.resume.tags.list.queryOptions());
|
||||
const { data: resumes } = useQuery(orpc.resume.list.queryOptions({ input: { tags, sort } }));
|
||||
|
||||
const filteredResumes = useMemo(() => {
|
||||
const list = resumes ?? [];
|
||||
const query = search.trim().toLowerCase();
|
||||
if (!query) return list;
|
||||
return list.filter(
|
||||
(resume) => resume.name.toLowerCase().includes(query) || resume.slug.toLowerCase().includes(query),
|
||||
);
|
||||
}, [resumes, search]);
|
||||
|
||||
const tagOptions = useMemo(() => {
|
||||
if (!allTags) return [];
|
||||
return allTags.map((tag) => ({ value: tag, label: tag }));
|
||||
@@ -51,15 +72,32 @@ function RouteComponent() {
|
||||
|
||||
const sortOptions = useMemo(() => {
|
||||
return [
|
||||
{ value: "lastUpdatedAt", label: i18n.t("Last Updated") },
|
||||
{ value: "createdAt", label: i18n.t("Created") },
|
||||
{ value: "name", label: i18n.t("Name") },
|
||||
{ value: "lastUpdatedAt", label: i18n.t(msg`Last Updated`) },
|
||||
{ value: "createdAt", label: i18n.t(msg`Created`) },
|
||||
{ value: "name", label: i18n.t(msg`Name`) },
|
||||
];
|
||||
}, [i18n]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<DashboardHeader icon={ReadCvLogoIcon} title={t`Resumes`} />
|
||||
<DashboardHeader
|
||||
icon={ReadCvLogoIcon}
|
||||
title={t`Resumes`}
|
||||
actions={
|
||||
(resumes?.length ?? 0) > 0 ? (
|
||||
<>
|
||||
<Button size="sm" variant="outline" onClick={() => openDialog("resume.create", undefined)}>
|
||||
<PlusIcon />
|
||||
<Trans>Create</Trans>
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => openDialog("resume.import", undefined)}>
|
||||
<DownloadSimpleIcon />
|
||||
<Trans>Import</Trans>
|
||||
</Button>
|
||||
</>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
@@ -94,6 +132,22 @@ function RouteComponent() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{(resumes?.length ?? 0) > 5 && (
|
||||
<InputGroup className="w-56">
|
||||
<InputGroupAddon align="inline-start">
|
||||
<MagnifyingGlassIcon />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
value={search}
|
||||
placeholder={t`Search resumes...`}
|
||||
onChange={(event) => {
|
||||
const value = event.target.value;
|
||||
void navigate({ search: (prev: Search) => ({ ...prev, search: value }) });
|
||||
}}
|
||||
/>
|
||||
</InputGroup>
|
||||
)}
|
||||
|
||||
<Tabs className="ltr:ms-auto rtl:me-auto" value={view}>
|
||||
<TabsList>
|
||||
<TabsTrigger
|
||||
@@ -119,7 +173,11 @@ function RouteComponent() {
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{view === "list" ? <ListView resumes={resumes ?? []} /> : <GridView resumes={resumes ?? []} />}
|
||||
{view === "list" ? (
|
||||
<ListView resumes={filteredResumes} hasResumes={(resumes?.length ?? 0) > 0} />
|
||||
) : (
|
||||
<GridView resumes={filteredResumes} hasResumes={(resumes?.length ?? 0) > 0} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { createFileRoute, Outlet, redirect, useRouter } from "@tanstack/react-router";
|
||||
import { SidebarProvider } from "@reactive-resume/ui/components/sidebar";
|
||||
import { createNoindexFollowMeta } from "@/libs/seo";
|
||||
@@ -30,9 +31,16 @@ function RouteComponent() {
|
||||
|
||||
return (
|
||||
<SidebarProvider open={sidebarState} onOpenChange={handleSidebarOpenChange}>
|
||||
<a
|
||||
href="#main-content"
|
||||
className="sr-only rounded-md bg-popover px-4 py-2 text-sm ring-2 ring-ring focus:not-sr-only focus:absolute focus:inset-s-2 focus:top-2 focus:z-[100]"
|
||||
>
|
||||
<Trans>Skip to main content</Trans>
|
||||
</a>
|
||||
|
||||
<DashboardSidebar />
|
||||
|
||||
<main className="@container flex-1 p-4 md:ps-2">
|
||||
<main id="main-content" className="@container flex-1 p-4 md:ps-2">
|
||||
<Outlet />
|
||||
</main>
|
||||
</SidebarProvider>
|
||||
|
||||
Reference in New Issue
Block a user