| "use client"; |
|
|
| import type { RouterOutputs } from "@api/trpc/routers/_app"; |
| import { isMimeTypeSupportedForProcessing } from "@midday/documents/utils"; |
| import { Button } from "@midday/ui/button"; |
| import { cn } from "@midday/ui/cn"; |
| import { Icons } from "@midday/ui/icons"; |
| import { Skeleton } from "@midday/ui/skeleton"; |
| import { useMutation, useQueryClient } from "@tanstack/react-query"; |
| import { memo, useEffect, useState } from "react"; |
| import { FilePreview } from "@/components/file-preview"; |
| import { VaultItemTags } from "@/components/vault/vault-item-tags"; |
| import { useDocumentParams } from "@/hooks/use-document-params"; |
| import { useTRPC } from "@/trpc/client"; |
| import { isStaleProcessing } from "@/utils/document"; |
| import { VaultItemActions } from "./vault-item-actions"; |
|
|
| type Props = { |
| data: Partial<RouterOutputs["documents"]["get"]["data"][number]> & { |
| id: string; |
| name?: string | null; |
| metadata: Record<string, unknown>; |
| pathTokens: string[]; |
| title: string; |
| summary: string; |
| createdAt?: string | Date | null; |
| documentTagAssignments?: Array<{ |
| documentTag: { id: string; name: string; slug: string }; |
| }>; |
| }; |
| small?: boolean; |
| }; |
|
|
| export const VaultItem = memo(function VaultItem({ data, small }: Props) { |
| const { setParams } = useDocumentParams(); |
| const trpc = useTRPC(); |
| const queryClient = useQueryClient(); |
|
|
| |
| const [isReprocessing, setIsReprocessing] = useState(false); |
|
|
| const mimetype = (data?.metadata as { mimetype?: string })?.mimetype; |
| const isSupported = mimetype |
| ? isMimeTypeSupportedForProcessing(mimetype) |
| : false; |
|
|
| const isFailed = data.processingStatus === "failed"; |
|
|
| |
| const needsClassification = |
| data.processingStatus === "completed" && !data.title; |
|
|
| |
| const staleProcessing = isStaleProcessing( |
| data.processingStatus, |
| data.createdAt, |
| ); |
|
|
| |
| const isLoading = data.processingStatus === "pending" && !staleProcessing; |
|
|
| |
| useEffect(() => { |
| if (isReprocessing) { |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const isSuccessfullyCompleted = |
| data.processingStatus === "completed" && !!data.title; |
| if ( |
| isSuccessfullyCompleted || |
| isFailed || |
| isLoading || |
| needsClassification |
| ) { |
| setIsReprocessing(false); |
| } |
| } |
| }, [ |
| isReprocessing, |
| isLoading, |
| isFailed, |
| needsClassification, |
| data.processingStatus, |
| data.title, |
| ]); |
|
|
| |
| const showRetry = |
| isSupported && (isFailed || needsClassification || staleProcessing); |
|
|
| |
| const displayName = |
| data?.title || data?.name?.split("/").at(-1) || "Document"; |
|
|
| const reprocessMutation = useMutation( |
| trpc.documents.reprocessDocument.mutationOptions({ |
| onSuccess: () => { |
| queryClient.invalidateQueries({ |
| queryKey: trpc.documents.get.infiniteQueryKey(), |
| }); |
|
|
| queryClient.invalidateQueries({ |
| queryKey: trpc.documents.get.queryKey(), |
| }); |
| }, |
| onError: () => { |
| |
| setIsReprocessing(false); |
| }, |
| }), |
| ); |
|
|
| const handleReprocess = (e: React.MouseEvent) => { |
| e.stopPropagation(); |
| setIsReprocessing(true); |
| reprocessMutation.mutate({ id: data.id }); |
| }; |
|
|
| |
| const showSkeleton = isLoading || isReprocessing; |
|
|
| return ( |
| <div |
| className={cn( |
| "h-72 border relative flex text-muted-foreground p-4 flex-col gap-3 hover:bg-muted dark:hover:bg-[#141414] transition-colors duration-200 group cursor-pointer", |
| small && "h-48", |
| )} |
| onClick={() => { |
| setParams({ documentId: data.id }); |
| }} |
| > |
| {/* Status badge - top right */} |
| {showRetry && !showSkeleton && ( |
| <div className="absolute top-4 right-4 z-10"> |
| <span className="px-2 py-0.5 rounded-full text-[11px] bg-[#FFD02B]/10 text-[#FFD02B] dark:bg-[#FFD02B]/10 dark:text-[#FFD02B]"> |
| Processing incomplete |
| </span> |
| </div> |
| )} |
| |
| {/* Actions menu - top right (hidden when badge is showing) */} |
| {!(showRetry && !showSkeleton) && ( |
| <div |
| className="absolute top-4 right-4 opacity-0 group-hover:opacity-100 transition-opacity duration-200 z-10" |
| onClick={(e) => { |
| e.stopPropagation(); |
| }} |
| > |
| <VaultItemActions |
| id={data.id} |
| filePath={data.pathTokens ?? []} |
| hideDelete={small} |
| /> |
| </div> |
| )} |
| |
| <div |
| className={cn( |
| "w-[60px] h-[84px] flex items-center justify-center relative", |
| small && "w-[45px] h-[63px]", |
| mimetype?.startsWith("image/") && "bg-border", |
| )} |
| > |
| {mimetype === "image/heic" && showSkeleton ? ( |
| // NOTE: We convert the heic images to jpeg in the backend, so we need to wait for the image to be processed |
| // Otherwise the image will be a broken image, and the cache will not be updated |
| <Skeleton className="absolute inset-0 w-full h-full" /> |
| ) : ( |
| <FilePreview |
| filePath={data?.pathTokens?.join("/") ?? ""} |
| mimeType={mimetype ?? ""} |
| lazy |
| fixedSize={ |
| small ? { width: 45, height: 63 } : { width: 60, height: 84 } |
| } |
| /> |
| )} |
| </div> |
| |
| <div className="flex flex-col text-left flex-1"> |
| <h2 className="text-sm text-primary line-clamp-1 mb-2 mt-3"> |
| {showSkeleton ? <Skeleton className="w-[80%] h-4" /> : displayName} |
| </h2> |
| |
| {showSkeleton ? ( |
| <Skeleton className="w-[50%] h-4" /> |
| ) : !showRetry ? ( |
| <p className="text-xs text-muted-foreground line-clamp-3"> |
| {data?.summary} |
| </p> |
| ) : null} |
| </div> |
| |
| <div className="mt-auto" onClick={(e) => e.stopPropagation()}> |
| {showRetry ? ( |
| showSkeleton ? ( |
| <div className="flex flex-col gap-2"> |
| <Skeleton className="h-8 w-full" /> |
| </div> |
| ) : ( |
| <Button |
| variant="outline" |
| size="sm" |
| onClick={handleReprocess} |
| disabled={reprocessMutation.isPending} |
| className="gap-2 w-full text-primary" |
| > |
| <Icons.Refresh className="size-3" /> |
| Re-analyze document |
| </Button> |
| ) |
| ) : !small ? ( |
| <VaultItemTags |
| tags={data?.documentTagAssignments ?? []} |
| isLoading={showSkeleton} |
| /> |
| ) : null} |
| </div> |
| </div> |
| ); |
| }); |
|
|