update files-state, add toast, add zustand

This commit is contained in:
smanylov
2025-12-29 14:00:02 +07:00
parent fffd38a812
commit 198a01d026
9 changed files with 134 additions and 52 deletions
+11
View File
@@ -21,6 +21,7 @@
"react": "19.2.0", "react": "19.2.0",
"react-dom": "19.2.0", "react-dom": "19.2.0",
"recharts": "^3.5.1", "recharts": "^3.5.1",
"sonner": "^2.0.7",
"tailwindcss": "^4.1.17", "tailwindcss": "^4.1.17",
"zod": "^4.1.13", "zod": "^4.1.13",
"zustand": "^5.0.9" "zustand": "^5.0.9"
@@ -7119,6 +7120,16 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/sonner": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz",
"integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==",
"license": "MIT",
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc",
"react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc"
}
},
"node_modules/source-map-js": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+2 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "no-copy-frontend", "name": "no-copy-frontend",
"version": "0.9.0", "version": "0.10.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev -p 2999", "dev": "next dev -p 2999",
@@ -22,6 +22,7 @@
"react": "19.2.0", "react": "19.2.0",
"react-dom": "19.2.0", "react-dom": "19.2.0",
"recharts": "^3.5.1", "recharts": "^3.5.1",
"sonner": "^2.0.7",
"tailwindcss": "^4.1.17", "tailwindcss": "^4.1.17",
"zod": "^4.1.13", "zod": "^4.1.13",
"zustand": "^5.0.9" "zustand": "^5.0.9"
+2
View File
@@ -3,6 +3,7 @@ import type { Metadata } from "next";
import { NextIntlClientProvider, hasLocale } from 'next-intl'; import { NextIntlClientProvider, hasLocale } from 'next-intl';
import { notFound } from 'next/navigation'; import { notFound } from 'next/navigation';
import { routing } from '@/i18n/routing'; import { routing } from '@/i18n/routing';
import { ToastProvider } from '@/app/providers/ToastProvider';
import "../styles/globals.css"; import "../styles/globals.css";
import "../styles/global-styles.scss"; import "../styles/global-styles.scss";
@@ -33,6 +34,7 @@ export default async function RootLayout({
<NextIntlClientProvider> <NextIntlClientProvider>
<Providers> <Providers>
{children} {children}
<ToastProvider />
</Providers> </Providers>
</NextIntlClientProvider> </NextIntlClientProvider>
</body> </body>
+69 -47
View File
@@ -14,23 +14,38 @@ import {
import { IconImageFile, IconVideoFile, IconAudioFile, IconEye, IconDoubleArrowRight, IconArrowRight, IconDoubleArrowLeft, IconArrowLeft, IconArrowUp, IconArrowDown, IconFilter, IconFileOpen, IconShieldExclamation, IconDelete } from '@/app/ui/icons/icons'; import { IconImageFile, IconVideoFile, IconAudioFile, IconEye, IconDoubleArrowRight, IconArrowRight, IconDoubleArrowLeft, IconArrowLeft, IconArrowUp, IconArrowDown, IconFilter, IconFileOpen, IconShieldExclamation, IconDelete } from '@/app/ui/icons/icons';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import DropDownList from '@/app/components/dropDownList'; import DropDownList from '@/app/components/dropDownList';
import { useQuery } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { getUserFilesData, removeUserFile } from '@/app/actions/fileEntity'; import { getUserFilesData, removeUserFile } from '@/app/actions/fileEntity';
import ModalWindow from '@/app/components/modalWindow'; import ModalWindow from '@/app/components/modalWindow';
import { toast } from 'sonner';
// Типы данных
type FileType = 'image' | 'video' | 'audio'; type FileType = 'image' | 'video' | 'audio';
type FileItem = { type FileItem = {
id: string; id: string;
fileName: string; fileName: string;
fileType: FileType; fileType: string;
violations?: number | undefined; violations?: number | undefined;
checks?: number | undefined; checks?: number | undefined;
lastCheck?: number; // timestamp в миллисекундах lastCheck?: number;
_original?: ApiFile;
};
type ApiFile = {
id: string;
originalFileName: string;
mimeType: string;
violations: number;
checks: number;
updatedAt: string;
};
type ApiResponse = {
files?: ApiFile[];
}; };
// Иконки для типов файлов // Иконки для типов файлов
const FileTypeIcon = ({ type }: { type: FileType }) => { const FileTypeIcon = ({ type }: { type: string }) => {
switch (type) { switch (type) {
case 'image': case 'image':
return <span className="text-[#f08c00]"> return <span className="text-[#f08c00]">
@@ -69,23 +84,18 @@ const formatDateTime = (timestamp: number) => {
export default function TanstakFilesTable() { export default function TanstakFilesTable() {
const { const {
data: userFilesData, data: tableData,
isLoading, isLoading,
isError, isError,
error, error,
refetch } = useQuery<ApiResponse, Error, FileItem[], ['userFilesData']>({
} = useQuery({
queryKey: ['userFilesData'], queryKey: ['userFilesData'],
queryFn: () => { queryFn: getUserFilesData,
return getUserFilesData();
}
});
const [tableData, setTableData] = useState<FileItem[]>([]); select: (data: ApiResponse): FileItem[] => {
if (!data?.files) return [];
useEffect(() => { return data.files.map((item: ApiFile) => {
if (userFilesData?.files) {
const formattedData = userFilesData.files.map((item: any) => {
const [datePart, timePart] = item.updatedAt.split(' '); const [datePart, timePart] = item.updatedAt.split(' ');
const [day, month, year] = datePart.split('-').map(Number); const [day, month, year] = datePart.split('-').map(Number);
const [hours, minutes, seconds] = timePart.split(':').map(Number); const [hours, minutes, seconds] = timePart.split(':').map(Number);
@@ -97,13 +107,14 @@ export default function TanstakFilesTable() {
fileType: item.mimeType.toLocaleLowerCase(), fileType: item.mimeType.toLocaleLowerCase(),
violations: item.violations, violations: item.violations,
checks: item.checks, checks: item.checks,
lastCheck: newDate lastCheck: newDate,
_original: item
}; };
}); });
},
});
setTableData(formattedData); const queryClient = useQueryClient();
}
}, [userFilesData]);
// Состояния // Состояния
const [sorting, setSorting] = useState<SortingState>([]); const [sorting, setSorting] = useState<SortingState>([]);
@@ -343,7 +354,7 @@ export default function TanstakFilesTable() {
<IconShieldExclamation /> <IconShieldExclamation />
</button> </button>
<button <button
onClick={() => handleRemove(row.original)} onClick={() => handleOpenWindowForRemove(row.original)}
className="px-3 py-1 bg-red-700 text-white rounded hover:bg-red-800 text-sm" className="px-3 py-1 bg-red-700 text-white rounded hover:bg-red-800 text-sm"
> >
<IconDelete /> <IconDelete />
@@ -370,7 +381,7 @@ export default function TanstakFilesTable() {
console.log(`Щиток: ${file.fileName}`); console.log(`Щиток: ${file.fileName}`);
}; };
const handleRemove = async (file: FileItem) => { const handleOpenWindowForRemove = async (file: FileItem) => {
setOpenWindowChildren(() => { setOpenWindowChildren(() => {
return ( return (
@@ -381,18 +392,12 @@ export default function TanstakFilesTable() {
<div className="mt-2 mb-8 text-center">{file.fileName}</div> <div className="mt-2 mb-8 text-center">{file.fileName}</div>
<div className="flex justify-center gap-4"> <div className="flex justify-center gap-4">
<button className="btn-primary btn-modal" <button className="btn-primary btn-modal"
onClick={async () => { onClick={() => {
const response = await removeUserFile(file.id); deleteMutation.mutate(file.id)
if (response === file.id) {
setTableData(
prev => prev.filter(item => item.id !== file.id)
)
setOpenWindow(false);
setOpenWindowChildren(null);
}
}} }}
disabled={deleteMutation.isPending}
> >
{t('yes')} {deleteMutation.isPending ? '...' : t('yes')}
</button> </button>
<button className="btn-primary btn-modal" <button className="btn-primary btn-modal"
onClick={() => { onClick={() => {
@@ -409,9 +414,41 @@ export default function TanstakFilesTable() {
setOpenWindow(true); setOpenWindow(true);
}; };
const deleteMutation = useMutation({
mutationFn: removeUserFile,
onMutate: async (fileId: string) => {
await queryClient.cancelQueries({ queryKey: ['userFilesData'] });
queryClient.setQueryData<ApiResponse>(['userFilesData'], (old) => {
if (!old?.files) return old;
return {
...old,
files: old.files.filter(file => file.id !== fileId)
};
});
},
onError: () => {
queryClient.invalidateQueries({ queryKey: ['userFilesData'] });
toast.error(t('error'));
},
onSuccess: (response, fileId) => {
if (response === fileId) {
setOpenWindow(false);
setOpenWindowChildren(null);
toast.success(t('file-has-been-deleted'));
}
},
});
// Фильтрация по типу файла и дате // Фильтрация по типу файла и дате
const filteredData = useMemo(() => { const filteredData = useMemo(() => {
let result = tableData; let result = tableData;
if (!result) {
return [];
}
// Фильтр по типу файла // Фильтр по типу файла
if (typeFilter !== 'all') { if (typeFilter !== 'all') {
@@ -503,21 +540,6 @@ export default function TanstakFilesTable() {
<div className="p-6 mx-auto tanstak-table"> <div className="p-6 mx-auto tanstak-table">
<ModalWindow children={openWindowChildren} state={openWindow} callBack={setOpenWindow}></ModalWindow> <ModalWindow children={openWindowChildren} state={openWindow} callBack={setOpenWindow}></ModalWindow>
{/* Фильтры */} {/* Фильтры */}
<button
className="mb-2 btn-primary rounded disabled:opacity-50 disabled:cursor-not-allowed"
onClick={() => refetch()}
disabled={isLoading}
>
{isLoading ? (
<span className="flex items-center gap-2">
refreshing...
</span>
) : (
<span className="flex items-center gap-2">
refresh
</span>
)}
</button>
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 pb-4 pt-0 px-0"> <div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 pb-4 pt-0 px-0">
<div className="flex flex-col md:flex-row gap-4 w-full md:w-auto"> <div className="flex flex-col md:flex-row gap-4 w-full md:w-auto">
<div className="flex flex-col w-50"> <div className="flex flex-col w-50">
+4 -2
View File
@@ -5,6 +5,7 @@ import { IconShieldAdd } from '@/app/ui/icons/icons';
import { fileUpload, cancelUpload, chunkUpload, checkChunkStatus } from '@/app/actions/fileUpload'; import { fileUpload, cancelUpload, chunkUpload, checkChunkStatus } from '@/app/actions/fileUpload';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { useNavigationBlocker } from '@/app/hooks/useNavigationBlocker'; import { useNavigationBlocker } from '@/app/hooks/useNavigationBlocker';
import { useQueryClient } from '@tanstack/react-query';
interface SelectedFile { interface SelectedFile {
file: File; file: File;
preview: string | undefined; preview: string | undefined;
@@ -38,7 +39,7 @@ export default function UploadSectionFile({ fileType, allowedExtensions, maxFile
const [uploadId, setUploadId] = useState<string | null>(null); const [uploadId, setUploadId] = useState<string | null>(null);
const [isFileUploaded, setIsFileUploaded] = useState<boolean>(false); const [isFileUploaded, setIsFileUploaded] = useState<boolean>(false);
const [uploadProgress, setUploadProgress] = useState<number>(0); const [uploadProgress, setUploadProgress] = useState<number>(0);
const queryClient = useQueryClient();
const isCancelledRef = useRef(false); const isCancelledRef = useRef(false);
const t = useTranslations('Global'); const t = useTranslations('Global');
@@ -92,7 +93,7 @@ export default function UploadSectionFile({ fileType, allowedExtensions, maxFile
default: default:
break; break;
} };
const acceptString = useMemo(() => { const acceptString = useMemo(() => {
if (!allowedExtensions || !Array.isArray(allowedExtensions)) { if (!allowedExtensions || !Array.isArray(allowedExtensions)) {
@@ -292,6 +293,7 @@ export default function UploadSectionFile({ fileType, allowedExtensions, maxFile
const chunkStatus = await checkChunkStatus(response.upload_id); const chunkStatus = await checkChunkStatus(response.upload_id);
if (chunkStatus.message_body.missing_chunks === 0) { if (chunkStatus.message_body.missing_chunks === 0) {
setIsFileUploaded(true); setIsFileUploaded(true);
await queryClient.invalidateQueries({ queryKey: ['userFilesData'] });
} else { } else {
throw new Error('Not all chunks were uploaded'); throw new Error('Not all chunks were uploaded');
} }
+17
View File
@@ -0,0 +1,17 @@
'use client';
import { Toaster } from 'sonner';
export function ToastProvider() {
return (
<Toaster
position="bottom-left"
expand={false}
richColors
closeButton={false}
duration={1000}
visibleToasts={3}
gap={12}
/>
);
}
+23
View File
@@ -0,0 +1,23 @@
import { create } from 'zustand'
import { devtools } from 'zustand/middleware'
interface Store {
value: any
setValue: (value: any) => void
}
export const useStoreWithDevtools = create<Store>()(
devtools(
(set) => ({
value: 0,
setValue: (value) => set({ value }),
}),
{
name: 'DevStore', // имя для DevTools
}
)
)
/* пример */
/* const setText = useStoreWithDevtools(s => s.setValue); */
/* const text = useStoreWithDevtools(s => s.value); */
+3 -1
View File
@@ -148,7 +148,9 @@
"yes": "yes", "yes": "yes",
"no": "no", "no": "no",
"you-sure-you-want-to-delete": "Are you sure you want to delete?", "you-sure-you-want-to-delete": "Are you sure you want to delete?",
"reports-and-analytics": "Reports and analytics" "reports-and-analytics": "Reports and analytics",
"file-has-been-deleted": "The file has been deleted",
"error": "Error"
}, },
"Login-register-form": { "Login-register-form": {
"and": "and", "and": "and",
+3 -1
View File
@@ -148,7 +148,9 @@
"yes": "да", "yes": "да",
"no": "нет", "no": "нет",
"you-sure-you-want-to-delete": "Уверены, что хотите удалить?", "you-sure-you-want-to-delete": "Уверены, что хотите удалить?",
"reports-and-analytics": "Отчёты и аналитика" "reports-and-analytics": "Отчёты и аналитика",
"file-has-been-deleted": "Файл удален",
"error": "Ошбка"
}, },
"Login-register-form": { "Login-register-form": {
"and": "и", "and": "и",