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:
Daniel Luiz Alves
2025-04-11 14:59:52 -03:00
parent 0a738430e7
commit e55f090235
15 changed files with 438 additions and 29 deletions

View File

@@ -68,8 +68,8 @@
"downloadError": "Failed to download file" "downloadError": "Failed to download file"
}, },
"fileSelector": { "fileSelector": {
"availableFiles": "Available Files ({{count}})", "availableFiles": "Available Files ({count})",
"shareFiles": "Share Files ({{count}})", "shareFiles": "Share Files ({count})",
"searchPlaceholder": "Search files...", "searchPlaceholder": "Search files...",
"noMatchingFiles": "No matching files", "noMatchingFiles": "No matching files",
"noAvailableFiles": "No files available", "noAvailableFiles": "No files available",
@@ -249,7 +249,7 @@
"recipientSelector": { "recipientSelector": {
"emailPlaceholder": "Enter recipient email", "emailPlaceholder": "Enter recipient email",
"add": "Add", "add": "Add",
"recipients": "Recipients ({{count}})", "recipients": "Recipients ({count})",
"notifyAll": "Notify All", "notifyAll": "Notify All",
"noRecipients": "No recipients added yet", "noRecipients": "No recipients added yet",
"addSuccess": "Recipient added successfully", "addSuccess": "Recipient added successfully",
@@ -285,7 +285,7 @@
}, },
"searchBar": { "searchBar": {
"placeholder": "Search files...", "placeholder": "Search files...",
"results": "Found {{filtered}} of {{total}} files" "results": "Found {filtered} of {total} files"
}, },
"settings": { "settings": {
"groups": { "groups": {
@@ -379,7 +379,7 @@
} }
}, },
"buttons": { "buttons": {
"save": "Save {{group}}" "save": "Save {group}"
}, },
"errors": { "errors": {
"loadFailed": "Failed to load settings", "loadFailed": "Failed to load settings",
@@ -387,7 +387,7 @@
}, },
"messages": { "messages": {
"noChanges": "No changes to save", "noChanges": "No changes to save",
"updateSuccess": "{{group}} settings updated successfully" "updateSuccess": "{group} settings updated successfully"
}, },
"title": "Settings", "title": "Settings",
"breadcrumb": "Settings", "breadcrumb": "Settings",
@@ -412,8 +412,8 @@
}, },
"details": { "details": {
"untitled": "Untitled Share", "untitled": "Untitled Share",
"created": "Created: {{date}}", "created": "Created: {date}",
"expires": "Expires: {{date}}" "expires": "Expires: {date}"
}, },
"notFound": { "notFound": {
"title": "Share Not Found", "title": "Share Not Found",
@@ -498,7 +498,7 @@
"title": "All Shares", "title": "All Shares",
"createButton": "Create Share", "createButton": "Create Share",
"placeholder": "Search shares...", "placeholder": "Search shares...",
"results": "Found {{filtered}} of {{total}} shares" "results": "Found {filtered} of {total} shares"
}, },
"pageTitle": "Shares" "pageTitle": "Shares"
}, },
@@ -561,7 +561,7 @@
}, },
"errors": { "errors": {
"loadFailed": "Failed to load users", "loadFailed": "Failed to load users",
"submitFailed": "Failed to {{mode}} user", "submitFailed": "Failed to {mode} user",
"deleteFailed": "Failed to delete user", "deleteFailed": "Failed to delete user",
"statusUpdateFailed": "Failed to update user status" "statusUpdateFailed": "Failed to update user status"
}, },
@@ -580,7 +580,7 @@
}, },
"delete": { "delete": {
"title": "Confirm Delete User", "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" "confirm": "Delete User"
}, },
"form": { "form": {
@@ -601,7 +601,7 @@
}, },
"status": { "status": {
"title": "Confirm Status Change", "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", "activate": "activate",
"deactivate": "deactivate", "deactivate": "deactivate",
"user": "User" "user": "User"

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

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

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

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

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

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

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

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

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

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

View File

@@ -6,6 +6,13 @@ import { useTranslations } from "next-intl";
import { Navbar } from "@/components/layout/navbar"; import { Navbar } from "@/components/layout/navbar";
import { DefaultFooter } from "@/components/ui/default-footer"; import { DefaultFooter } from "@/components/ui/default-footer";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
interface FileManagerLayoutProps { interface FileManagerLayoutProps {
children: ReactNode; children: ReactNode;
@@ -36,24 +43,24 @@ export function FileManagerLayout({
</div> </div>
<Separator /> <Separator />
{showBreadcrumb && breadcrumbLabel && ( {showBreadcrumb && breadcrumbLabel && (
<nav className="flex" aria-label="Breadcrumb"> <Breadcrumb>
<ol className="flex items-center gap-2"> <BreadcrumbList>
<li className="flex items-center"> <BreadcrumbItem>
<Link <BreadcrumbLink asChild>
href="/dashboard" <Link href="/dashboard" className="flex items-center">
className="flex items-center text-muted-foreground hover:text-foreground transition-colors" <IconLayoutDashboard size={20} className="mr-2" />
> {t("navigation.dashboard")}
<IconLayoutDashboard className="h-4 w-4 mr-1" /> </Link>
{t("navigation.dashboard")} </BreadcrumbLink>
</Link> </BreadcrumbItem>
</li> <BreadcrumbSeparator />
<li className="flex items-center before:content-['/'] before:mx-2 before:text-muted-foreground"> <BreadcrumbItem>
<span className="flex items-center"> <span className="flex items-center gap-2">
{icon} {breadcrumbLabel} {icon} {breadcrumbLabel}
</span> </span>
</li> </BreadcrumbItem>
</ol> </BreadcrumbList>
</nav> </Breadcrumb>
)} )}
</div> </div>

View File

@@ -1,3 +1,4 @@
"use client";
/* eslint-disable jsx-a11y/media-has-caption */ /* eslint-disable jsx-a11y/media-has-caption */
import { getDownloadUrl } from "@/http/endpoints"; import { getDownloadUrl } from "@/http/endpoints";
import { getFileIcon } from "@/utils/file-icons"; import { getFileIcon } from "@/utils/file-icons";

View File

@@ -1,3 +1,5 @@
"use client";
import { getPresignedUrl, registerFile } from "@/http/endpoints"; import { getPresignedUrl, registerFile } from "@/http/endpoints";
import { generateSafeFileName } from "@/utils/file-utils"; import { generateSafeFileName } from "@/utils/file-utils";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -12,7 +14,7 @@ import { Progress } from "@/components/ui/progress";
import axios from "axios"; import axios from "axios";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useState, useRef, useEffect } from "react"; 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"; import { toast } from "sonner";
interface UploadFileModalProps { interface UploadFileModalProps {

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