feat(categories): add schema image thumbnails to grid and tree views
Enrich category children with batch-fetched schema images and leaf detection on the API side. Display thumbnails in grid cards with shimmer loading placeholders and background prefetching. Add TanStack Query caching for category children. Fix insert race condition with onConflictDoNothing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import { Link, useNavigate } from "@tanstack/react-router";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { ChevronRight, ArrowLeft, Loader2 } from "lucide-react";
|
||||
import { Card, CardContent } from "@sase/ui";
|
||||
import { api } from "@/lib/api-client";
|
||||
@@ -10,6 +11,8 @@ interface Category {
|
||||
name: string;
|
||||
children?: Category[];
|
||||
partCount?: number;
|
||||
schemaImageUrl?: string | null;
|
||||
parentId?: string | null;
|
||||
}
|
||||
|
||||
interface CategoryGridProps {
|
||||
@@ -25,28 +28,97 @@ interface BreadcrumbItem {
|
||||
|
||||
export function CategoryGrid({ categories, vehicleId }: CategoryGridProps) {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [breadcrumbs, setBreadcrumbs] = useState<BreadcrumbItem[]>([]);
|
||||
const [currentCategories, setCurrentCategories] = useState(categories);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [prefetchingIds, setPrefetchingIds] = useState<Set<string>>(new Set());
|
||||
|
||||
// Prefetch schema images for leaf categories without images (sequential to avoid PL24 session conflicts)
|
||||
const prefetchedRef = useRef<Set<string>>(new Set());
|
||||
useEffect(() => {
|
||||
const leafsWithoutImage = currentCategories.filter(
|
||||
(c) =>
|
||||
c.children !== undefined &&
|
||||
c.children.length === 0 &&
|
||||
!c.schemaImageUrl &&
|
||||
!prefetchedRef.current.has(c.id),
|
||||
);
|
||||
if (leafsWithoutImage.length === 0) return;
|
||||
|
||||
for (const c of leafsWithoutImage) prefetchedRef.current.add(c.id);
|
||||
setPrefetchingIds(new Set(leafsWithoutImage.map((c) => c.id)));
|
||||
|
||||
const parentId = currentCategories[0]?.parentId;
|
||||
let didCancel = false;
|
||||
|
||||
// Sequential fetch to avoid PL24 session conflicts
|
||||
(async () => {
|
||||
for (const c of leafsWithoutImage) {
|
||||
if (didCancel) break;
|
||||
try {
|
||||
await api.get(`/vehicles/${vehicleId}/categories/${c.id}`);
|
||||
} catch {}
|
||||
}
|
||||
if (didCancel || !parentId) return;
|
||||
try {
|
||||
const refreshed = await api.get<Category[]>(
|
||||
`/categories/${parentId}/children`,
|
||||
);
|
||||
if (didCancel || !refreshed?.length) return;
|
||||
setCurrentCategories((prev) =>
|
||||
prev.map((c) => {
|
||||
const updated = refreshed.find((r) => r.id === c.id);
|
||||
return updated?.schemaImageUrl
|
||||
? { ...c, schemaImageUrl: updated.schemaImageUrl }
|
||||
: c;
|
||||
}),
|
||||
);
|
||||
} catch {}
|
||||
if (!didCancel) setPrefetchingIds(new Set());
|
||||
})();
|
||||
|
||||
return () => {
|
||||
didCancel = true;
|
||||
};
|
||||
}, [currentCategories, vehicleId]);
|
||||
|
||||
const handleDrillDown = useCallback(
|
||||
async (category: Category) => {
|
||||
// If children are already loaded, drill down immediately
|
||||
if (category.children && category.children.length > 0) {
|
||||
// Show cached children immediately if available, then enrich from API
|
||||
const cachedChildren = category.children && category.children.length > 0 ? category.children : null;
|
||||
|
||||
if (cachedChildren) {
|
||||
setBreadcrumbs((prev) => [
|
||||
...prev,
|
||||
{ id: category.id, name: category.name, categories: currentCategories },
|
||||
]);
|
||||
setCurrentCategories(category.children);
|
||||
setCurrentCategories(cachedChildren);
|
||||
|
||||
// Enrich with schema images from API in background
|
||||
queryClient
|
||||
.fetchQuery({
|
||||
queryKey: ["category-children", category.id],
|
||||
queryFn: () =>
|
||||
api.get<Category[]>(`/categories/${category.id}/children`),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
})
|
||||
.then((enriched) => {
|
||||
if (enriched?.length) setCurrentCategories(enriched);
|
||||
})
|
||||
.catch(() => {});
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch children from API
|
||||
// Fetch children from API via TanStack Query cache
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await api.get<Category[]>(
|
||||
`/categories/${category.id}/children`,
|
||||
);
|
||||
const data = await queryClient.fetchQuery({
|
||||
queryKey: ["category-children", category.id],
|
||||
queryFn: () =>
|
||||
api.get<Category[]>(`/categories/${category.id}/children`),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
if (data && data.length > 0) {
|
||||
setBreadcrumbs((prev) => [
|
||||
...prev,
|
||||
@@ -66,7 +138,7 @@ export function CategoryGrid({ categories, vehicleId }: CategoryGridProps) {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[currentCategories, navigate, vehicleId],
|
||||
[currentCategories, navigate, vehicleId, queryClient],
|
||||
);
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
@@ -167,6 +239,8 @@ export function CategoryGrid({ categories, vehicleId }: CategoryGridProps) {
|
||||
name={category.name}
|
||||
partCount={category.partCount}
|
||||
Icon={Icon}
|
||||
schemaImageUrl={category.schemaImageUrl}
|
||||
isLoading={prefetchingIds.has(category.id)}
|
||||
/>
|
||||
</Link>
|
||||
);
|
||||
@@ -183,6 +257,8 @@ export function CategoryGrid({ categories, vehicleId }: CategoryGridProps) {
|
||||
name={category.name}
|
||||
partCount={category.partCount}
|
||||
Icon={Icon}
|
||||
schemaImageUrl={category.schemaImageUrl}
|
||||
isLoading={prefetchingIds.has(category.id)}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
@@ -197,11 +273,49 @@ function CategoryCard({
|
||||
name,
|
||||
partCount,
|
||||
Icon,
|
||||
schemaImageUrl,
|
||||
isLoading,
|
||||
}: {
|
||||
name: string;
|
||||
partCount?: number;
|
||||
Icon: React.ComponentType<{ className?: string }>;
|
||||
schemaImageUrl?: string | null;
|
||||
isLoading?: boolean;
|
||||
}) {
|
||||
const [imgLoaded, setImgLoaded] = useState(false);
|
||||
|
||||
if (schemaImageUrl || isLoading) {
|
||||
return (
|
||||
<Card className="border hover:border-foreground/20 hover:shadow-md transition-all cursor-pointer group h-full overflow-hidden">
|
||||
<CardContent className="p-0">
|
||||
<div className="relative aspect-[4/3] bg-muted overflow-hidden">
|
||||
{(!schemaImageUrl || !imgLoaded) && (
|
||||
<div className="absolute inset-0 bg-muted overflow-hidden">
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-background/60 to-transparent animate-[shimmer_1.5s_ease-in-out_infinite]" />
|
||||
</div>
|
||||
)}
|
||||
{schemaImageUrl && (
|
||||
<img
|
||||
src={schemaImageUrl}
|
||||
alt={name}
|
||||
className={`w-full h-full object-contain p-2 group-hover:scale-105 transition-transform ${imgLoaded ? "opacity-100" : "opacity-0"}`}
|
||||
onLoad={() => setImgLoaded(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-3 flex items-center justify-between gap-2">
|
||||
<span className="font-medium text-sm line-clamp-2">{name}</span>
|
||||
{partCount != null && partCount > 0 && (
|
||||
<span className="text-xs text-muted-foreground flex-shrink-0">
|
||||
{partCount} parça
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="border hover:border-foreground/20 hover:shadow-md transition-all cursor-pointer group h-full">
|
||||
<CardContent className="p-4">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { ChevronRight, ChevronDown, Loader2 } from "lucide-react";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { getCategoryIcon } from "@/lib/category-icons";
|
||||
@@ -9,78 +10,91 @@ interface Category {
|
||||
name: string;
|
||||
children?: Category[];
|
||||
partCount?: number;
|
||||
schemaImageUrl?: string | null;
|
||||
parentId?: string | null;
|
||||
}
|
||||
|
||||
interface CategoryTreeProps {
|
||||
categories: Category[];
|
||||
vehicleId: string;
|
||||
}
|
||||
|
||||
export function CategoryTree({ categories, vehicleId }: CategoryTreeProps) {
|
||||
export function CategoryTree({ categories, vehicleId }: { categories: Category[]; vehicleId: string }) {
|
||||
if (!categories || categories.length === 0) {
|
||||
return (
|
||||
<p className="py-4 text-center text-sm text-muted-foreground">
|
||||
Kategori bulunamadi.
|
||||
</p>
|
||||
);
|
||||
return <p className="py-4 text-center text-sm text-muted-foreground">Kategori bulunamadi.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{categories.map((category) => (
|
||||
<CategoryNode
|
||||
key={category.id}
|
||||
category={category}
|
||||
vehicleId={vehicleId}
|
||||
level={0}
|
||||
/>
|
||||
{categories.map((cat) => (
|
||||
<CategoryNode key={cat.id} category={cat} vehicleId={vehicleId} level={0} parentPrefetching={false} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CategoryNodeProps {
|
||||
category: Category;
|
||||
vehicleId: string;
|
||||
level: number;
|
||||
}
|
||||
|
||||
function CategoryNode({ category, vehicleId, level }: CategoryNodeProps) {
|
||||
function CategoryNode({ category, vehicleId, level, parentPrefetching }: {
|
||||
category: Category; vehicleId: string; level: number; parentPrefetching: boolean;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [children, setChildren] = useState<Category[]>(category.children || []);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [fetched, setFetched] = useState(category.children !== undefined);
|
||||
const [prefetching, setPrefetching] = useState(false);
|
||||
const prefetchedRef = useRef(false);
|
||||
|
||||
const hasChildren = children.length > 0;
|
||||
const isLeaf = fetched && children.length === 0;
|
||||
|
||||
const handleExpand = useCallback(async () => {
|
||||
if (expanded) {
|
||||
setExpanded(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// If we already fetched children, just expand
|
||||
if (fetched) {
|
||||
setExpanded(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Lazy-load children from API
|
||||
if (expanded) { setExpanded(false); return; }
|
||||
if (fetched) { setExpanded(true); return; }
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await api.get<Category[]>(`/categories/${category.id}/children`);
|
||||
const data = await queryClient.fetchQuery({
|
||||
queryKey: ["category-children", category.id],
|
||||
queryFn: () => api.get<Category[]>(`/categories/${category.id}/children`),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
setChildren(data || []);
|
||||
setFetched(true);
|
||||
setExpanded(true);
|
||||
} catch {
|
||||
// If fetch fails, mark as fetched (leaf node)
|
||||
setFetched(true);
|
||||
setExpanded(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [expanded, fetched, category.id]);
|
||||
}, [expanded, fetched, category.id, queryClient]);
|
||||
|
||||
// Prefetch schema images for leaf children when expanded
|
||||
useEffect(() => {
|
||||
if (!expanded || prefetchedRef.current) return;
|
||||
const leafs = children.filter(
|
||||
(c) => c.children !== undefined && c.children.length === 0 && !c.schemaImageUrl,
|
||||
);
|
||||
if (leafs.length === 0) return;
|
||||
prefetchedRef.current = true;
|
||||
setPrefetching(true);
|
||||
const parentId = children[0]?.parentId;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
for (const c of leafs) {
|
||||
if (cancelled) break;
|
||||
try { await api.get(`/vehicles/${vehicleId}/categories/${c.id}`); } catch {}
|
||||
}
|
||||
if (cancelled || !parentId) { if (!cancelled) setPrefetching(false); return; }
|
||||
try {
|
||||
const refreshed = await api.get<Category[]>(`/categories/${parentId}/children`);
|
||||
if (!cancelled && refreshed?.length) {
|
||||
setChildren((prev) => prev.map((c) => {
|
||||
const u = refreshed.find((r) => r.id === c.id);
|
||||
return u?.schemaImageUrl ? { ...c, schemaImageUrl: u.schemaImageUrl } : c;
|
||||
}));
|
||||
}
|
||||
} catch {}
|
||||
if (!cancelled) setPrefetching(false);
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [expanded, children, vehicleId]);
|
||||
|
||||
const Icon = getCategoryIcon(category.name);
|
||||
const isShimmering = parentPrefetching && isLeaf && !category.schemaImageUrl;
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -95,57 +109,72 @@ function CategoryNode({ category, vehicleId, level }: CategoryNodeProps) {
|
||||
) : isLeaf ? (
|
||||
<span className="h-5 w-5" />
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleExpand}
|
||||
className="flex h-5 w-5 items-center justify-center rounded hover:bg-muted"
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
)}
|
||||
<button type="button" onClick={handleExpand}
|
||||
className="flex h-5 w-5 items-center justify-center rounded hover:bg-muted">
|
||||
{expanded ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
)}
|
||||
{(() => {
|
||||
const Icon = getCategoryIcon(category.name);
|
||||
return <Icon className="h-4 w-4 text-muted-foreground" />;
|
||||
})()}
|
||||
<SchemaIcon Icon={Icon} schemaImageUrl={category.schemaImageUrl} name={category.name} shimmer={isShimmering} />
|
||||
{isLeaf ? (
|
||||
<Link
|
||||
to="/dashboard/vehicles/$id/categories/$categoryId"
|
||||
<Link to="/dashboard/vehicles/$id/categories/$categoryId"
|
||||
params={{ id: vehicleId, categoryId: category.id }}
|
||||
className="flex-1 truncate hover:underline"
|
||||
>
|
||||
className="flex-1 truncate hover:underline">
|
||||
{category.name}
|
||||
</Link>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleExpand}
|
||||
className="flex-1 truncate text-left hover:underline"
|
||||
>
|
||||
<button type="button" onClick={handleExpand}
|
||||
className="flex-1 truncate text-left hover:underline">
|
||||
{category.name}
|
||||
</button>
|
||||
)}
|
||||
{category.partCount != null && category.partCount > 0 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{category.partCount}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">{category.partCount}</span>
|
||||
)}
|
||||
</div>
|
||||
{hasChildren && expanded && (
|
||||
<div>
|
||||
{children.map((child) => (
|
||||
<CategoryNode
|
||||
key={child.id}
|
||||
category={child}
|
||||
vehicleId={vehicleId}
|
||||
level={level + 1}
|
||||
/>
|
||||
<CategoryNode key={child.id} category={child} vehicleId={vehicleId}
|
||||
level={level + 1} parentPrefetching={prefetching} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SchemaIcon({ Icon, schemaImageUrl, name, shimmer }: {
|
||||
Icon: React.ComponentType<{ className?: string }>;
|
||||
schemaImageUrl?: string | null;
|
||||
name: string;
|
||||
shimmer?: boolean;
|
||||
}) {
|
||||
const [show, setShow] = useState(false);
|
||||
|
||||
if (shimmer) {
|
||||
return (
|
||||
<div className="h-4 w-4 rounded bg-muted overflow-hidden relative flex-shrink-0">
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-background/60 to-transparent animate-[shimmer_1.5s_ease-in-out_infinite]" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!schemaImageUrl) {
|
||||
return <Icon className="h-4 w-4 text-muted-foreground flex-shrink-0" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex-shrink-0"
|
||||
onMouseEnter={() => setShow(true)} onMouseLeave={() => setShow(false)}>
|
||||
<Icon className="h-4 w-4 text-primary cursor-pointer" />
|
||||
{show && (
|
||||
<div className="absolute left-0 bottom-full mb-2 z-50 w-56 rounded-lg border bg-card shadow-lg overflow-hidden pointer-events-none">
|
||||
<div className="aspect-[4/3] bg-muted">
|
||||
<img src={schemaImageUrl} alt={name} className="w-full h-full object-contain p-1" />
|
||||
</div>
|
||||
<div className="px-2 py-1.5 text-xs font-medium truncate border-t">{name}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -37,6 +37,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(100%); }
|
||||
}
|
||||
|
||||
.dark {
|
||||
--color-background: #0a0a0a;
|
||||
--color-foreground: #fafafa;
|
||||
|
||||
Reference in New Issue
Block a user