import "./DocumentsView.css"; import { useState, useMemo, useCallback, useEffect, useRef, type ChangeEvent } from "react"; import { useTranslation } from "react-i18next"; import type { TFunction } from "i18next"; import { ArrowLeft, AudioLines, Download, ExternalLink, FileText, FileType, Image as ImageIcon, Package, Pencil, RefreshCw, Search, Video, X, Eye, EyeOff } from "lucide-react"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import type { Artifact, ArtifactWithTask, ColumnId, TaskDocumentWithTask, TaskDetail } from "@fusion/core"; import type { ToastType } from "../hooks/useToast"; import { artifactMediaUrl, fetchArtifact, fetchTaskDetail, fetchWorkspaceFileContent, putTaskDocument, saveWorkspaceFileContent, type MarkdownFileEntry } from "../api"; import { useArtifacts } from "../hooks/useArtifacts"; import { useDocuments } from "../hooks/useDocuments"; import { useProjectMarkdownFiles } from "../hooks/useProjectMarkdownFiles"; import { useSelectionComment } from "../hooks/useSelectionComment"; import { SelectionCommentPopover } from "./SelectionCommentPopover"; import { FileEditor } from "./FileEditor"; import { LoadingSpinner } from "./LoadingSpinner"; import { ArtifactsGallery, getArtifactCategory, type ArtifactCategory } from "./ArtifactsGallery"; import { ViewHeader } from "./ViewHeader"; import { useColumnLabel } from "../i18n/labels"; const MOBILE_BREAKPOINT = 768; type DocumentsTab = "project" | "tasks" | "artifacts"; type SelectedTaskItem = { kind: "document" | "artifact"; id: string }; type GroupedTaskItems = { taskId: string; taskTitle?: string; taskColumn?: string; documents: TaskDocumentWithTask[]; artifacts: ArtifactWithTask[]; latestUpdated: string; }; const TASK_ARTIFACT_CATEGORY_ICONS: Record = { image: ImageIcon, doc: FileText, pdf: FileType, video: Video, audio: AudioLines, other: Package, }; export interface DocumentsViewProps { projectId?: string; addToast: (message: string, type?: ToastType) => void; onOpenDetail: (task: TaskDetail) => void; onOpenArtifactTaskDetail?: (task: TaskDetail) => void; onSendSelectionToTask?: (description: string) => void; } function formatTimestamp(iso?: string): string { if (!iso) return ""; return new Date(iso).toLocaleString(); } function formatFileSize(bytes: number): string { if (bytes < 1024) { return `${bytes} B`; } if (bytes < 1024 * 1024) { return `${(bytes / 1024).toFixed(bytes >= 10 * 1024 ? 0 : 1)} KB`; } return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; } function getTaskColumnStatusDotClass(taskColumn: string): string { if (taskColumn === "done") return "status-dot status-dot--online"; if (taskColumn === "archived") return "status-dot status-dot--offline"; if (taskColumn === "todo" || taskColumn === "triage") return "status-dot status-dot--pending"; return "status-dot status-dot--connecting"; } function getTaskArtifactCategoryLabel(t: TFunction<"app">, category: ArtifactCategory): string { switch (category) { case "image": return t("documents.artifactCategoryImage", "Image"); case "doc": return t("documents.artifactCategoryDoc", "Document"); case "pdf": return t("documents.artifactCategoryPdf", "PDF"); case "video": return t("documents.artifactCategoryVideo", "Video"); case "audio": return t("documents.artifactCategoryAudioSingle", "Audio"); case "other": return t("documents.artifactCategoryOtherSingle", "Other"); } } function artifactMatchesTaskSearch(artifact: ArtifactWithTask, normalizedQuery: string): boolean { if (!normalizedQuery) return true; return [artifact.title, artifact.description, artifact.taskId, artifact.taskTitle, artifact.mimeType, artifact.authorId] .some((value) => value?.toLowerCase().includes(normalizedQuery)); } function artifactHasInlineText(artifact: Pick): boolean { const mime = artifact.mimeType?.toLowerCase().split(";", 1)[0] ?? ""; return Boolean(artifact.content) || artifact.type === "document" || mime.startsWith("text/") || mime === "application/json" || mime === "application/xml"; } interface TaskArtifactInlineViewerProps { artifact: ArtifactWithTask; projectId?: string; content: string | null; loading: boolean; error: string | null; renderMarkdown: boolean; onToggleMarkdown: () => void; onOpenTask: (taskId: string) => void; t: TFunction<"app">; } function TaskArtifactInlineViewer({ artifact, projectId, content, loading, error, renderMarkdown, onToggleMarkdown, onOpenTask, t }: TaskArtifactInlineViewerProps) { const [mediaError, setMediaError] = useState(null); const category = getArtifactCategory(artifact); const categoryLabel = getTaskArtifactCategoryLabel(t, category); const title = artifact.title || t("documents.untitledArtifact", "Untitled artifact"); const mediaUrl = artifactMediaUrl(artifact.id, projectId); const hasInlineText = category === "doc" && artifactHasInlineText(artifact); useEffect(() => { setMediaError(null); }, [artifact.id]); const mediaErrorNode = mediaError ?

{mediaError}

: null; const body = (() => { if (category === "image") { return <>{mediaErrorNode}{title} setMediaError(t("documents.artifactMediaFailed", "Failed to load artifact preview."))} />; } if (category === "video") { return <>{mediaErrorNode}