v1.5.6: boneyard-js skeleton migration, TanStack Query refactor, rate-limit config
- Replace hand-coded skeleton CSS/JSX with boneyard-js auto-generated bones - Remove skeleton.css and @keyframes shimmer from base.css - Add <Skeleton> wrappers with fixtures to all 25+ page components - Generate 20 bone captures via boneyard CLI (CDP auth-gated capture) - Refactor data fetching from useEffect+useState to TanStack Query - Extract query hooks into src/admin/lib/queries/ and apiAdapter - Add usePaginatedQuery hook replacing useApiCall/useListData - Fix parseFloat || 0 anti-pattern in OfferDetail and OffersTemplates inputs - Fix customer_id mandatory validation on offer creation - Fix leave-requests comma-separated status filter (Prisma enum in: []) - Add cross-entity cache invalidation for orders/offers/invoices/projects - Make rate limits configurable via env vars (RATE_LIMIT_MAX, RATE_LIMIT_REFRESH, etc.) - Add boneyard.config.json with routes and breakpoints Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,11 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { useState, useRef } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { projectFilesOptions } from "../lib/queries/projects";
|
||||
import { useAlert } from "../context/AlertContext";
|
||||
import ConfirmModal from "./ConfirmModal";
|
||||
import apiFetch from "../utils/api";
|
||||
import { Skeleton } from "boneyard-js/react";
|
||||
import ProjectFileManagerFixture from "../fixtures/ProjectFileManagerFixture";
|
||||
|
||||
const API_BASE = "/api/admin";
|
||||
|
||||
@@ -196,14 +200,11 @@ export default function ProjectFileManager({
|
||||
hasNasFolder,
|
||||
}: ProjectFileManagerProps) {
|
||||
const alert = useAlert();
|
||||
const queryClient = useQueryClient();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const isCancelling = useRef(false);
|
||||
|
||||
const [items, setItems] = useState<FileItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [currentPath, setCurrentPath] = useState("");
|
||||
const [breadcrumb, setBreadcrumb] = useState<string[]>([""]);
|
||||
const [fullPath, setFullPath] = useState("");
|
||||
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
@@ -217,59 +218,25 @@ export default function ProjectFileManager({
|
||||
|
||||
const [deleteTarget, setDeleteTarget] = useState<FileItem | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
const canManage = hasPermission("projects.files");
|
||||
|
||||
const fetchFiles = useCallback(
|
||||
async (path = "", options: { ignore?: boolean } = {}) => {
|
||||
setLoading(true);
|
||||
setErrorMessage(null);
|
||||
try {
|
||||
const params = new URLSearchParams({ project_id: String(projectId) });
|
||||
if (path) {
|
||||
params.set("path", path);
|
||||
}
|
||||
const res = await apiFetch(`${API_BASE}/project-files?${params}`);
|
||||
if (options.ignore) return;
|
||||
if (res.status === 401) return;
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setItems(data.data.items || []);
|
||||
setBreadcrumb(data.data.breadcrumb || [""]);
|
||||
setCurrentPath(data.data.path || "");
|
||||
setFullPath(data.data.full_path || "");
|
||||
} else if (res.status === 404) {
|
||||
setItems([]);
|
||||
setBreadcrumb([""]);
|
||||
} else {
|
||||
setErrorMessage(data.error || "Nepodařilo se načíst soubory");
|
||||
}
|
||||
} catch {
|
||||
if (!options.ignore) {
|
||||
setErrorMessage("Chyba připojení");
|
||||
}
|
||||
} finally {
|
||||
if (!options.ignore) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[projectId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const opts = { ignore: false };
|
||||
fetchFiles("", opts);
|
||||
return () => {
|
||||
opts.ignore = true;
|
||||
};
|
||||
}, [fetchFiles]);
|
||||
const {
|
||||
data: filesData,
|
||||
isPending: filesLoading,
|
||||
error: filesError,
|
||||
} = useQuery(projectFilesOptions(projectId, currentPath));
|
||||
const items = filesData?.items ?? [];
|
||||
const breadcrumb = filesData?.breadcrumb ?? [""];
|
||||
const fullPath = filesData?.full_path ?? "";
|
||||
const errorMessage = filesError
|
||||
? filesError.message || "Nepodařilo se načíst soubory"
|
||||
: null;
|
||||
|
||||
const navigateTo = (path: string) => {
|
||||
setNewFolderMode(false);
|
||||
setRenamingItem(null);
|
||||
fetchFiles(path);
|
||||
setCurrentPath(path);
|
||||
};
|
||||
|
||||
const handleBreadcrumbClick = (index: number) => {
|
||||
@@ -332,7 +299,9 @@ export default function ProjectFileManager({
|
||||
? "Soubor byl nahrán"
|
||||
: `Nahráno ${successCount} souborů`;
|
||||
alert.success(msg);
|
||||
fetchFiles(currentPath);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["projects", String(projectId), "files"],
|
||||
});
|
||||
}
|
||||
if (errorMsg) {
|
||||
alert.error(errorMsg);
|
||||
@@ -383,7 +352,9 @@ export default function ProjectFileManager({
|
||||
alert.success("Složka byla vytvořena");
|
||||
setNewFolderMode(false);
|
||||
setNewFolderName("");
|
||||
fetchFiles(currentPath);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["projects", String(projectId), "files"],
|
||||
});
|
||||
} else {
|
||||
alert.error(data.error || "Nepodařilo se vytvořit složku");
|
||||
}
|
||||
@@ -444,7 +415,9 @@ export default function ProjectFileManager({
|
||||
? "Složka byla smazána"
|
||||
: "Soubor byl smazán",
|
||||
);
|
||||
fetchFiles(currentPath);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["projects", String(projectId), "files"],
|
||||
});
|
||||
} else {
|
||||
alert.error(data.error || "Nepodařilo se smazat");
|
||||
}
|
||||
@@ -479,7 +452,9 @@ export default function ProjectFileManager({
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
alert.success("Přejmenováno");
|
||||
fetchFiles(currentPath);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["projects", String(projectId), "files"],
|
||||
});
|
||||
} else {
|
||||
alert.error(data.error || "Nepodařilo se přejmenovat");
|
||||
}
|
||||
@@ -495,32 +470,15 @@ export default function ProjectFileManager({
|
||||
setRenameValue(item.name);
|
||||
};
|
||||
|
||||
if (loading && items.length === 0 && !errorMessage) {
|
||||
if (filesLoading && items.length === 0 && !errorMessage) {
|
||||
return (
|
||||
<div className="admin-card">
|
||||
<div className="admin-card-body">
|
||||
<h3 className="admin-card-title">Soubory</h3>
|
||||
<div className="admin-skeleton" style={{ padding: 0, gap: "0.5rem" }}>
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<div key={i} className="admin-skeleton-row">
|
||||
<div
|
||||
className="admin-skeleton-line"
|
||||
style={{
|
||||
width: "18px",
|
||||
height: "18px",
|
||||
borderRadius: "4px",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="admin-skeleton-line"
|
||||
style={{ width: `${60 + i * 10}%` }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton
|
||||
name="project-file-manager"
|
||||
loading={filesLoading && items.length === 0}
|
||||
fixture={<ProjectFileManagerFixture />}
|
||||
>
|
||||
<div />
|
||||
</Skeleton>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -710,7 +668,7 @@ export default function ProjectFileManager({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{items.length === 0 && !loading ? (
|
||||
{items.length === 0 && !filesLoading ? (
|
||||
<div className="fm-empty">
|
||||
<svg
|
||||
width="32"
|
||||
|
||||
Reference in New Issue
Block a user