mirror of
https://github.com/kyantech/Palmr.git
synced 2025-10-23 06:11:58 +00:00
feat(files): add file management components and hooks
Introduce new components and hooks for managing files, including file list, search bar, empty state, and modals. This includes the addition of a breadcrumb component for better navigation and the use of client-side rendering for specific components. The changes aim to improve the user experience and maintainability of the file management system.
This commit is contained in:
@@ -68,8 +68,8 @@
|
||||
"downloadError": "Failed to download file"
|
||||
},
|
||||
"fileSelector": {
|
||||
"availableFiles": "Available Files ({{count}})",
|
||||
"shareFiles": "Share Files ({{count}})",
|
||||
"availableFiles": "Available Files ({count})",
|
||||
"shareFiles": "Share Files ({count})",
|
||||
"searchPlaceholder": "Search files...",
|
||||
"noMatchingFiles": "No matching files",
|
||||
"noAvailableFiles": "No files available",
|
||||
@@ -249,7 +249,7 @@
|
||||
"recipientSelector": {
|
||||
"emailPlaceholder": "Enter recipient email",
|
||||
"add": "Add",
|
||||
"recipients": "Recipients ({{count}})",
|
||||
"recipients": "Recipients ({count})",
|
||||
"notifyAll": "Notify All",
|
||||
"noRecipients": "No recipients added yet",
|
||||
"addSuccess": "Recipient added successfully",
|
||||
@@ -285,7 +285,7 @@
|
||||
},
|
||||
"searchBar": {
|
||||
"placeholder": "Search files...",
|
||||
"results": "Found {{filtered}} of {{total}} files"
|
||||
"results": "Found {filtered} of {total} files"
|
||||
},
|
||||
"settings": {
|
||||
"groups": {
|
||||
@@ -379,7 +379,7 @@
|
||||
}
|
||||
},
|
||||
"buttons": {
|
||||
"save": "Save {{group}}"
|
||||
"save": "Save {group}"
|
||||
},
|
||||
"errors": {
|
||||
"loadFailed": "Failed to load settings",
|
||||
@@ -387,7 +387,7 @@
|
||||
},
|
||||
"messages": {
|
||||
"noChanges": "No changes to save",
|
||||
"updateSuccess": "{{group}} settings updated successfully"
|
||||
"updateSuccess": "{group} settings updated successfully"
|
||||
},
|
||||
"title": "Settings",
|
||||
"breadcrumb": "Settings",
|
||||
@@ -412,8 +412,8 @@
|
||||
},
|
||||
"details": {
|
||||
"untitled": "Untitled Share",
|
||||
"created": "Created: {{date}}",
|
||||
"expires": "Expires: {{date}}"
|
||||
"created": "Created: {date}",
|
||||
"expires": "Expires: {date}"
|
||||
},
|
||||
"notFound": {
|
||||
"title": "Share Not Found",
|
||||
@@ -498,7 +498,7 @@
|
||||
"title": "All Shares",
|
||||
"createButton": "Create Share",
|
||||
"placeholder": "Search shares...",
|
||||
"results": "Found {{filtered}} of {{total}} shares"
|
||||
"results": "Found {filtered} of {total} shares"
|
||||
},
|
||||
"pageTitle": "Shares"
|
||||
},
|
||||
@@ -561,7 +561,7 @@
|
||||
},
|
||||
"errors": {
|
||||
"loadFailed": "Failed to load users",
|
||||
"submitFailed": "Failed to {{mode}} user",
|
||||
"submitFailed": "Failed to {mode} user",
|
||||
"deleteFailed": "Failed to delete user",
|
||||
"statusUpdateFailed": "Failed to update user status"
|
||||
},
|
||||
@@ -580,7 +580,7 @@
|
||||
},
|
||||
"delete": {
|
||||
"title": "Confirm Delete User",
|
||||
"confirmation": "Are you sure you want to delete user {{firstName}} {{lastName}}? This action cannot be undone.",
|
||||
"confirmation": "Are you sure you want to delete user {firstName} {lastName}? This action cannot be undone.",
|
||||
"confirm": "Delete User"
|
||||
},
|
||||
"form": {
|
||||
@@ -601,7 +601,7 @@
|
||||
},
|
||||
"status": {
|
||||
"title": "Confirm Status Change",
|
||||
"confirmation": "Are you sure you want to {{action}} user {{firstName}} {{lastName}}?",
|
||||
"confirmation": "Are you sure you want to {action} user {firstName} {lastName}?",
|
||||
"activate": "activate",
|
||||
"deactivate": "deactivate",
|
||||
"user": "User"
|
||||
|
18
apps/app/src/app/dashboard/layout.tsx
Normal file
18
apps/app/src/app/dashboard/layout.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Metadata } from "next";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
interface LayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const translate = await getTranslations();
|
||||
|
||||
return {
|
||||
title: translate("dashboard.pageTitle"),
|
||||
};
|
||||
}
|
||||
|
||||
export default function DashboardLayout({ children }: LayoutProps) {
|
||||
return <>{children}</>;
|
||||
}
|
19
apps/app/src/app/files/components/empty-state.tsx
Normal file
19
apps/app/src/app/files/components/empty-state.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { EmptyStateProps } from "../types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { IconCloudUpload, IconFolder } from "@tabler/icons-react";
|
||||
|
||||
export function EmptyState({ onUpload }: EmptyStateProps) {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<div className="text-center py-6 flex flex-col items-center gap-2">
|
||||
<IconFolder className="h-8 w-8 text-muted-foreground" />
|
||||
<p className="text-muted-foreground">{t("emptyState.noFiles")}</p>
|
||||
<Button variant="default" size="sm" onClick={onUpload}>
|
||||
<IconCloudUpload className="mr-2 h-4 w-4" />
|
||||
{t("emptyState.uploadFile")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
36
apps/app/src/app/files/components/file-list.tsx
Normal file
36
apps/app/src/app/files/components/file-list.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { FileListProps } from "../types";
|
||||
import { EmptyState } from "./empty-state";
|
||||
import { Header } from "./header";
|
||||
import { SearchBar } from "./search-bar";
|
||||
import { FilesTable } from "@/components/tables/files-table";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
export function FileList({ files, filteredFiles, fileManager, searchQuery, onSearch, onUpload }: FileListProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<div className="flex flex-col gap-6">
|
||||
<Header onUpload={onUpload} />
|
||||
<SearchBar
|
||||
filteredCount={filteredFiles.length}
|
||||
searchQuery={searchQuery}
|
||||
totalFiles={files.length}
|
||||
onSearch={onSearch}
|
||||
/>
|
||||
|
||||
{files.length > 0 ? (
|
||||
<FilesTable
|
||||
files={filteredFiles}
|
||||
onDelete={fileManager.setFileToDelete}
|
||||
onDownload={fileManager.handleDownload}
|
||||
onPreview={fileManager.setPreviewFile}
|
||||
onRename={fileManager.setFileToRename}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState onUpload={onUpload} />
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
18
apps/app/src/app/files/components/header.tsx
Normal file
18
apps/app/src/app/files/components/header.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { HeaderProps } from "../types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { IconCloudUpload } from "@tabler/icons-react";
|
||||
|
||||
export function Header({ onUpload }: HeaderProps) {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-xl font-semibold">{t("files.title")}</h2>
|
||||
<Button variant="default" onClick={onUpload}>
|
||||
<IconCloudUpload className="mr-2 h-4 w-4" />
|
||||
{t("files.uploadFile")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
27
apps/app/src/app/files/components/search-bar.tsx
Normal file
27
apps/app/src/app/files/components/search-bar.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { SearchBarProps } from "../types";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { IconSearch } from "@tabler/icons-react";
|
||||
|
||||
export function SearchBar({ searchQuery, onSearch, totalFiles, filteredCount }: SearchBarProps) {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative max-w-xs">
|
||||
<IconSearch className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
className="pl-9"
|
||||
placeholder={t("searchBar.placeholder")}
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{searchQuery && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t("searchBar.results", { filtered: filteredCount, total: totalFiles })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
54
apps/app/src/app/files/hooks/use-files.ts
Normal file
54
apps/app/src/app/files/hooks/use-files.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import { useFileManager } from "@/hooks/use-file-manager";
|
||||
import { listFiles } from "@/http/endpoints";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export function useFiles() {
|
||||
const t = useTranslations();
|
||||
const [files, setFiles] = useState<any[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [isUploadModalOpen, setIsUploadModalOpen] = useState(false);
|
||||
|
||||
const loadFiles = async () => {
|
||||
try {
|
||||
const response = await listFiles();
|
||||
const allFiles = response.data.files || [];
|
||||
const sortedFiles = [...allFiles].sort(
|
||||
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||
);
|
||||
|
||||
setFiles(sortedFiles);
|
||||
} catch (error) {
|
||||
toast.error(t("files.loadError"));
|
||||
console.error(error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fileManager = useFileManager(loadFiles);
|
||||
const filteredFiles = files.filter((file) => file.name.toLowerCase().includes(searchQuery.toLowerCase()));
|
||||
|
||||
useEffect(() => {
|
||||
loadFiles();
|
||||
}, []);
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
files,
|
||||
searchQuery,
|
||||
modals: {
|
||||
isUploadModalOpen,
|
||||
onOpenUploadModal: () => setIsUploadModalOpen(true),
|
||||
onCloseUploadModal: () => setIsUploadModalOpen(false),
|
||||
},
|
||||
fileManager,
|
||||
filteredFiles,
|
||||
handleSearch: setSearchQuery,
|
||||
loadFiles,
|
||||
};
|
||||
}
|
18
apps/app/src/app/files/layout.tsx
Normal file
18
apps/app/src/app/files/layout.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Metadata } from "next";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
interface LayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const translate = await getTranslations();
|
||||
|
||||
return {
|
||||
title: translate("files.pageTitle"),
|
||||
};
|
||||
}
|
||||
|
||||
export default function DashboardLayout({ children }: LayoutProps) {
|
||||
return <>{children}</>;
|
||||
}
|
27
apps/app/src/app/files/modals/files-modals.tsx
Normal file
27
apps/app/src/app/files/modals/files-modals.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { FilesModalsProps } from "../types";
|
||||
import { FileActionsModals } from "@/components/modals/file-actions-modals";
|
||||
import { FilePreviewModal } from "@/components/modals/file-preview-modal";
|
||||
import { UploadFileModal } from "@/components/modals/upload-file-modal";
|
||||
|
||||
export function FilesModals({ fileManager, modals, onSuccess }: FilesModalsProps) {
|
||||
return (
|
||||
<>
|
||||
<UploadFileModal isOpen={modals.isUploadModalOpen} onClose={modals.onCloseUploadModal} onSuccess={onSuccess} />
|
||||
|
||||
<FilePreviewModal
|
||||
file={fileManager.previewFile || { name: "", objectName: "" }}
|
||||
isOpen={!!fileManager.previewFile}
|
||||
onClose={() => fileManager.setPreviewFile(null)}
|
||||
/>
|
||||
|
||||
<FileActionsModals
|
||||
fileToDelete={fileManager.fileToDelete}
|
||||
fileToRename={fileManager.fileToRename}
|
||||
onCloseDelete={() => fileManager.setFileToDelete(null)}
|
||||
onCloseRename={() => fileManager.setFileToRename(null)}
|
||||
onDelete={fileManager.handleDelete}
|
||||
onRename={fileManager.handleRename}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
39
apps/app/src/app/files/page.tsx
Normal file
39
apps/app/src/app/files/page.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { FileList } from "./components/file-list";
|
||||
import { useFiles } from "./hooks/use-files";
|
||||
import { FilesModals } from "./modals/files-modals";
|
||||
import { FileManagerLayout } from "@/components/layout/file-manager-layout";
|
||||
import { LoadingScreen } from "@/components/layout/loading-screen";
|
||||
import { IconFolderOpen } from "@tabler/icons-react";
|
||||
|
||||
|
||||
export default function FilesPage() {
|
||||
const t = useTranslations();
|
||||
|
||||
const { isLoading, files, searchQuery, modals, fileManager, filteredFiles, handleSearch, loadFiles } = useFiles();
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
|
||||
return (
|
||||
<FileManagerLayout
|
||||
breadcrumbLabel={t("files.breadcrumb")}
|
||||
icon={<IconFolderOpen size={20}/>}
|
||||
title={t("files.pageTitle")}
|
||||
>
|
||||
<FileList
|
||||
fileManager={fileManager}
|
||||
files={files}
|
||||
filteredFiles={filteredFiles}
|
||||
searchQuery={searchQuery}
|
||||
onSearch={handleSearch}
|
||||
onUpload={modals.onOpenUploadModal}
|
||||
/>
|
||||
|
||||
<FilesModals fileManager={fileManager} modals={modals} onSuccess={loadFiles} />
|
||||
</FileManagerLayout>
|
||||
);
|
||||
}
|
34
apps/app/src/app/files/types/index.ts
Normal file
34
apps/app/src/app/files/types/index.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { FileManagerHook } from "@/hooks/use-file-manager";
|
||||
|
||||
export interface EmptyStateProps {
|
||||
onUpload: () => void;
|
||||
}
|
||||
|
||||
export interface HeaderProps {
|
||||
onUpload: () => void;
|
||||
}
|
||||
|
||||
export interface FileListProps {
|
||||
files: any[];
|
||||
filteredFiles: any[];
|
||||
fileManager: FileManagerHook;
|
||||
searchQuery: string;
|
||||
onSearch: (query: string) => void;
|
||||
onUpload: () => void;
|
||||
}
|
||||
|
||||
export interface SearchBarProps {
|
||||
searchQuery: string;
|
||||
onSearch: (query: string) => void;
|
||||
totalFiles: number;
|
||||
filteredCount: number;
|
||||
}
|
||||
|
||||
export interface FilesModalsProps {
|
||||
fileManager: FileManagerHook;
|
||||
modals: {
|
||||
isUploadModalOpen: boolean;
|
||||
onCloseUploadModal: () => void;
|
||||
};
|
||||
onSuccess: () => Promise<void>;
|
||||
}
|
@@ -6,6 +6,13 @@ import { useTranslations } from "next-intl";
|
||||
import { Navbar } from "@/components/layout/navbar";
|
||||
import { DefaultFooter } from "@/components/ui/default-footer";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbSeparator,
|
||||
} from "@/components/ui/breadcrumb";
|
||||
|
||||
interface FileManagerLayoutProps {
|
||||
children: ReactNode;
|
||||
@@ -36,24 +43,24 @@ export function FileManagerLayout({
|
||||
</div>
|
||||
<Separator />
|
||||
{showBreadcrumb && breadcrumbLabel && (
|
||||
<nav className="flex" aria-label="Breadcrumb">
|
||||
<ol className="flex items-center gap-2">
|
||||
<li className="flex items-center">
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="flex items-center text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<IconLayoutDashboard className="h-4 w-4 mr-1" />
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href="/dashboard" className="flex items-center">
|
||||
<IconLayoutDashboard size={20} className="mr-2" />
|
||||
{t("navigation.dashboard")}
|
||||
</Link>
|
||||
</li>
|
||||
<li className="flex items-center before:content-['/'] before:mx-2 before:text-muted-foreground">
|
||||
<span className="flex items-center">
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<span className="flex items-center gap-2">
|
||||
{icon} {breadcrumbLabel}
|
||||
</span>
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
@@ -1,3 +1,4 @@
|
||||
"use client";
|
||||
/* eslint-disable jsx-a11y/media-has-caption */
|
||||
import { getDownloadUrl } from "@/http/endpoints";
|
||||
import { getFileIcon } from "@/utils/file-icons";
|
||||
|
@@ -1,3 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { getPresignedUrl, registerFile } from "@/http/endpoints";
|
||||
import { generateSafeFileName } from "@/utils/file-utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -12,7 +14,7 @@ import { Progress } from "@/components/ui/progress";
|
||||
import axios from "axios";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { IconCloudUpload, IconFile, IconFileText, IconPhoto, IconFileTypography, IconFileTypePdf } from "@tabler/icons-react";
|
||||
import { IconCloudUpload, IconFileText, IconPhoto, IconFileTypography, IconFileTypePdf } from "@tabler/icons-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface UploadFileModalProps {
|
||||
|
109
apps/app/src/components/ui/breadcrumb.tsx
Normal file
109
apps/app/src/components/ui/breadcrumb.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { ChevronRight, MoreHorizontal } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
|
||||
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />
|
||||
}
|
||||
|
||||
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
|
||||
return (
|
||||
<ol
|
||||
data-slot="breadcrumb-list"
|
||||
className={cn(
|
||||
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-item"
|
||||
className={cn("inline-flex items-center gap-1.5", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbLink({
|
||||
asChild,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"a"> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "a"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="breadcrumb-link"
|
||||
className={cn("hover:text-foreground transition-colors", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-page"
|
||||
role="link"
|
||||
aria-disabled="true"
|
||||
aria-current="page"
|
||||
className={cn("text-foreground font-normal", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbSeparator({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-separator"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("[&>svg]:size-3.5", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronRight />}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbEllipsis({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-ellipsis"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("flex size-9 items-center justify-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
<span className="sr-only">More</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Breadcrumb,
|
||||
BreadcrumbList,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
BreadcrumbEllipsis,
|
||||
}
|
Reference in New Issue
Block a user