mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-08 20:25:19 +02:00
feat: implement mobile long-press functionality in DocumentsTableShell for enhanced document interaction, and integrate Drawer components for improved UI consistency
This commit is contained in:
parent
454d94bec7
commit
7f3c647328
1 changed files with 207 additions and 67 deletions
|
|
@ -40,6 +40,13 @@ import {
|
||||||
ContextMenuTrigger,
|
ContextMenuTrigger,
|
||||||
} from "@/components/ui/context-menu";
|
} from "@/components/ui/context-menu";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
Drawer,
|
||||||
|
DrawerContent,
|
||||||
|
DrawerHandle,
|
||||||
|
DrawerHeader,
|
||||||
|
DrawerTitle,
|
||||||
|
} from "@/components/ui/drawer";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import {
|
import {
|
||||||
|
|
@ -271,6 +278,48 @@ function RowContextMenu({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function MobileCardWrapper({
|
||||||
|
onLongPress,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
onLongPress: () => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const didLongPressRef = useRef(false);
|
||||||
|
|
||||||
|
const clearTimer = useCallback(() => {
|
||||||
|
if (timerRef.current) {
|
||||||
|
clearTimeout(timerRef.current);
|
||||||
|
timerRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
// biome-ignore lint/a11y/useSemanticElements: touch-only long-press wrapper for mobile
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
onTouchStart={() => {
|
||||||
|
didLongPressRef.current = false;
|
||||||
|
timerRef.current = setTimeout(() => {
|
||||||
|
didLongPressRef.current = true;
|
||||||
|
onLongPress();
|
||||||
|
}, 500);
|
||||||
|
}}
|
||||||
|
onTouchMove={clearTimer}
|
||||||
|
onTouchEnd={(e) => {
|
||||||
|
clearTimer();
|
||||||
|
if (didLongPressRef.current) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onContextMenu={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function DocumentsTableShell({
|
export function DocumentsTableShell({
|
||||||
documents,
|
documents,
|
||||||
loading,
|
loading,
|
||||||
|
|
@ -313,6 +362,8 @@ export function DocumentsTableShell({
|
||||||
|
|
||||||
const [deleteDoc, setDeleteDoc] = useState<Document | null>(null);
|
const [deleteDoc, setDeleteDoc] = useState<Document | null>(null);
|
||||||
const [isDeleting, setIsDeleting] = useState(false);
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
const [mobileActionDoc, setMobileActionDoc] = useState<Document | null>(null);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
const desktopSentinelRef = useRef<HTMLDivElement>(null);
|
const desktopSentinelRef = useRef<HTMLDivElement>(null);
|
||||||
const mobileSentinelRef = useRef<HTMLDivElement>(null);
|
const mobileSentinelRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
@ -657,83 +708,85 @@ export function DocumentsTableShell({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<RowContextMenu
|
<MobileCardWrapper
|
||||||
key={doc.id}
|
key={doc.id}
|
||||||
doc={doc}
|
onLongPress={() => setMobileActionDoc(doc)}
|
||||||
onPreview={handleViewDocument}
|
>
|
||||||
onDelete={setDeleteDoc}
|
<div
|
||||||
searchSpaceId={searchSpaceId}
|
className={`relative px-3 py-2 transition-colors ${
|
||||||
>
|
isMentioned
|
||||||
<div
|
? "bg-primary/5"
|
||||||
className={`relative px-3 py-2 transition-colors ${
|
: "hover:bg-muted/20"
|
||||||
isMentioned
|
} ${canInteract && hasChatMode ? "cursor-pointer" : ""}`}
|
||||||
? "bg-primary/5"
|
>
|
||||||
: "hover:bg-muted/20"
|
{canInteract && hasChatMode && (
|
||||||
} ${canInteract && hasChatMode ? "cursor-pointer" : ""}`}
|
<button
|
||||||
>
|
type="button"
|
||||||
{canInteract && hasChatMode && (
|
className="absolute inset-0 z-0"
|
||||||
<button
|
aria-label={isMentioned ? `Remove ${doc.title} from chat` : `Add ${doc.title} to chat`}
|
||||||
type="button"
|
onClick={handleCardClick}
|
||||||
className="absolute inset-0 z-0"
|
/>
|
||||||
aria-label={isMentioned ? `Remove ${doc.title} from chat` : `Add ${doc.title} to chat`}
|
)}
|
||||||
onClick={handleCardClick}
|
<div className="relative z-10 flex items-center gap-3 pointer-events-none">
|
||||||
|
<span className="pointer-events-auto">
|
||||||
|
<Checkbox
|
||||||
|
checked={isMentioned}
|
||||||
|
onCheckedChange={() => handleCardClick()}
|
||||||
|
disabled={!canInteract}
|
||||||
|
aria-label={isMentioned ? "Remove from chat" : "Add to chat"}
|
||||||
|
className={`border-foreground data-[state=checked]:bg-primary data-[state=checked]:border-primary shrink-0 ${!canInteract ? "opacity-40 cursor-not-allowed" : ""}`}
|
||||||
/>
|
/>
|
||||||
)}
|
</span>
|
||||||
<div className="relative z-10 flex items-center gap-3 pointer-events-none">
|
<div className="flex-1 min-w-0">
|
||||||
<span className="pointer-events-auto">
|
<span className="truncate block text-sm text-foreground">
|
||||||
<Checkbox
|
{doc.title}
|
||||||
checked={isMentioned}
|
|
||||||
onCheckedChange={() => handleCardClick()}
|
|
||||||
disabled={!canInteract}
|
|
||||||
aria-label={isMentioned ? "Remove from chat" : "Add to chat"}
|
|
||||||
className={`border-foreground data-[state=checked]:bg-primary data-[state=checked]:border-primary shrink-0 ${!canInteract ? "opacity-40 cursor-not-allowed" : ""}`}
|
|
||||||
/>
|
|
||||||
</span>
|
</span>
|
||||||
<div className="flex-1 min-w-0">
|
</div>
|
||||||
<DocumentNameTooltip
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
doc={doc}
|
<span className="flex items-center justify-center">
|
||||||
className="truncate block text-sm text-foreground cursor-default"
|
{getDocumentTypeIcon(doc.document_type, "h-4 w-4")}
|
||||||
/>
|
</span>
|
||||||
</div>
|
<StatusIndicator status={doc.status} />
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<span className="flex items-center justify-center">
|
|
||||||
{getDocumentTypeIcon(doc.document_type, "h-4 w-4")}
|
|
||||||
</span>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="top">
|
|
||||||
{getDocumentTypeLabel(doc.document_type)}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
<StatusIndicator status={doc.status} />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</RowContextMenu>
|
</div>
|
||||||
|
</MobileCardWrapper>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{hasMore && <div ref={mobileSentinelRef} className="py-3" />}
|
{hasMore && <div ref={mobileSentinelRef} className="py-3" />}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Document Content Viewer */}
|
{/* Document Content Viewer */}
|
||||||
<Dialog open={!!viewingDoc} onOpenChange={(open) => !open && handleCloseViewer()}>
|
<Dialog open={!!viewingDoc} onOpenChange={(open) => !open && handleCloseViewer()}>
|
||||||
<DialogContent className="max-w-4xl max-h-[80vh] flex flex-col overflow-hidden pb-0">
|
<DialogContent className="max-w-4xl max-w-[92%] md:max-w-4xl max-h-[75vh] md:max-h-[80vh] flex flex-col overflow-hidden pb-0 p-3 md:p-6 gap-2 md:gap-4">
|
||||||
<DialogHeader className="flex-shrink-0">
|
<DialogHeader className="flex-shrink-0">
|
||||||
<DialogTitle>{viewingDoc?.title}</DialogTitle>
|
<DialogTitle className="text-sm md:text-lg leading-tight pr-6">
|
||||||
</DialogHeader>
|
{viewingDoc?.title}
|
||||||
<div className="mt-4 overflow-y-auto flex-1 min-h-0 px-6 select-text">
|
</DialogTitle>
|
||||||
{viewingLoading ? (
|
</DialogHeader>
|
||||||
<div className="flex items-center justify-center py-12">
|
<div
|
||||||
<Spinner size="lg" className="text-muted-foreground" />
|
className={[
|
||||||
</div>
|
"overflow-y-auto flex-1 min-h-0 px-1 md:px-6 select-text",
|
||||||
) : (
|
"max-md:text-xs",
|
||||||
<MarkdownViewer content={viewingContent} />
|
"max-md:[&_h1]:text-base! max-md:[&_h1]:mt-3!",
|
||||||
)}
|
"max-md:[&_h2]:text-sm! max-md:[&_h2]:mt-2!",
|
||||||
</div>
|
"max-md:[&_h3]:text-xs! max-md:[&_h3]:mt-2!",
|
||||||
</DialogContent>
|
"max-md:[&_h4]:text-xs!",
|
||||||
</Dialog>
|
"max-md:[&_td]:text-[11px]! max-md:[&_td]:px-2! max-md:[&_td]:py-1.5!",
|
||||||
|
"max-md:[&_th]:text-[11px]! max-md:[&_th]:px-2! max-md:[&_th]:py-1.5!",
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
{viewingLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Spinner size="lg" className="text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<MarkdownViewer content={viewingContent} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
{/* Delete Confirmation Dialog */}
|
{/* Delete Confirmation Dialog */}
|
||||||
<AlertDialog open={!!deleteDoc} onOpenChange={(open) => !open && setDeleteDoc(null)}>
|
<AlertDialog open={!!deleteDoc} onOpenChange={(open) => !open && setDeleteDoc(null)}>
|
||||||
|
|
@ -760,6 +813,93 @@ export function DocumentsTableShell({
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
|
||||||
|
{/* Mobile Document Actions Drawer */}
|
||||||
|
<Drawer open={!!mobileActionDoc} onOpenChange={(open) => !open && setMobileActionDoc(null)}>
|
||||||
|
<DrawerContent>
|
||||||
|
<DrawerHandle />
|
||||||
|
<DrawerHeader className="text-left">
|
||||||
|
<DrawerTitle className="break-words text-base">
|
||||||
|
{mobileActionDoc?.title}
|
||||||
|
</DrawerTitle>
|
||||||
|
<div className="space-y-0.5 text-xs mt-1">
|
||||||
|
<p>
|
||||||
|
<span className="text-muted-foreground">Owner:</span>{" "}
|
||||||
|
{mobileActionDoc?.created_by_name ||
|
||||||
|
mobileActionDoc?.created_by_email ||
|
||||||
|
"—"}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<span className="text-muted-foreground">Created:</span>{" "}
|
||||||
|
{mobileActionDoc
|
||||||
|
? formatAbsoluteDate(mobileActionDoc.created_at)
|
||||||
|
: ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</DrawerHeader>
|
||||||
|
<div className="px-4 pb-6 flex flex-col gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="justify-start gap-2"
|
||||||
|
onClick={() => {
|
||||||
|
if (mobileActionDoc) handleViewDocument(mobileActionDoc);
|
||||||
|
setMobileActionDoc(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
Preview
|
||||||
|
</Button>
|
||||||
|
{mobileActionDoc &&
|
||||||
|
EDITABLE_DOCUMENT_TYPES.includes(
|
||||||
|
mobileActionDoc.document_type as (typeof EDITABLE_DOCUMENT_TYPES)[number]
|
||||||
|
) && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="justify-start gap-2"
|
||||||
|
disabled={
|
||||||
|
mobileActionDoc.status?.state === "pending" ||
|
||||||
|
mobileActionDoc.status?.state === "processing" ||
|
||||||
|
(mobileActionDoc.document_type === "FILE" &&
|
||||||
|
mobileActionDoc.status?.state === "failed")
|
||||||
|
}
|
||||||
|
onClick={() => {
|
||||||
|
if (mobileActionDoc) {
|
||||||
|
router.push(
|
||||||
|
`/dashboard/${searchSpaceId}/editor/${mobileActionDoc.id}`
|
||||||
|
);
|
||||||
|
setMobileActionDoc(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PenLine className="h-4 w-4" />
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{mobileActionDoc &&
|
||||||
|
!NON_DELETABLE_DOCUMENT_TYPES.includes(
|
||||||
|
mobileActionDoc.document_type as (typeof NON_DELETABLE_DOCUMENT_TYPES)[number]
|
||||||
|
) && (
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
className="justify-start gap-2"
|
||||||
|
disabled={
|
||||||
|
mobileActionDoc.status?.state === "pending" ||
|
||||||
|
mobileActionDoc.status?.state === "processing"
|
||||||
|
}
|
||||||
|
onClick={() => {
|
||||||
|
if (mobileActionDoc) {
|
||||||
|
setDeleteDoc(mobileActionDoc);
|
||||||
|
setMobileActionDoc(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DrawerContent>
|
||||||
|
</Drawer>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue