Files
wehub-resource-sync e4dcfc49aa
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:00:43 +08:00

378 lines
13 KiB
TypeScript

"use client";
import { useCallback, useMemo, useRef, useState, type DragEvent } from "react";
import { useTranslation } from "react-i18next";
import { AlertTriangle, CheckCircle2, FileText, Files, X } from "lucide-react";
import type { KnowledgeUploadPolicy } from "@/lib/knowledge-api";
import {
formatFileSize,
mergeSelectedFiles,
selectionFileId,
validateFiles,
type ValidatedFileSelection,
} from "@/lib/knowledge-helpers";
interface DropState {
active: boolean;
invalid: boolean;
draggedCount: number;
}
const EMPTY_DROP_STATE: DropState = {
active: false,
invalid: false,
draggedCount: 0,
};
interface FileDropZoneProps {
files: File[];
onChange: (files: File[]) => void;
uploadPolicy: KnowledgeUploadPolicy;
disabled?: boolean;
compact?: boolean;
hidePolicyHint?: boolean;
}
export default function FileDropZone({
files,
onChange,
uploadPolicy,
disabled = false,
compact = false,
hidePolicyHint = false,
}: FileDropZoneProps) {
const { t } = useTranslation();
const inputRef = useRef<HTMLInputElement>(null);
const dirInputRef = useRef<HTMLInputElement>(null);
const depthRef = useRef(0);
const [dropState, setDropState] = useState<DropState>(EMPTY_DROP_STATE);
// `webkitdirectory`/`directory` are non-standard attrs (not in React's
// typings) but widely supported — set them imperatively to pick a folder.
const setDirInput = useCallback((el: HTMLInputElement | null) => {
dirInputRef.current = el;
if (el) {
el.setAttribute("webkitdirectory", "");
el.setAttribute("directory", "");
}
}, []);
const selection = useMemo<ValidatedFileSelection>(
() => validateFiles(files, uploadPolicy, t),
[files, uploadPolicy, t],
);
const previewDropped = useCallback(
(incoming: File[]) => {
const validated = validateFiles(incoming, uploadPolicy, t);
return {
count: incoming.length,
invalid: validated.invalidFiles.length > 0,
};
},
[t, uploadPolicy],
);
const reset = useCallback(() => {
depthRef.current = 0;
setDropState(EMPTY_DROP_STATE);
}, []);
const handleEnter = useCallback(
(event: DragEvent<HTMLElement>) => {
if (disabled) return;
if (!Array.from(event.dataTransfer.types).includes("Files")) return;
event.preventDefault();
event.stopPropagation();
depthRef.current += 1;
const incoming = Array.from(event.dataTransfer.items)
.filter((item) => item.kind === "file")
.map((item) => item.getAsFile())
.filter((file): file is File => Boolean(file));
const preview = previewDropped(incoming);
setDropState({
active: true,
invalid: preview.invalid,
draggedCount: preview.count,
});
},
[disabled, previewDropped],
);
const handleOver = useCallback(
(event: DragEvent<HTMLElement>) => {
if (disabled) return;
if (!Array.from(event.dataTransfer.types).includes("Files")) return;
event.preventDefault();
event.stopPropagation();
event.dataTransfer.dropEffect = "copy";
const incoming = Array.from(event.dataTransfer.items)
.filter((item) => item.kind === "file")
.map((item) => item.getAsFile())
.filter((file): file is File => Boolean(file));
const preview = previewDropped(incoming);
setDropState({
active: true,
invalid: preview.invalid,
draggedCount: preview.count,
});
},
[disabled, previewDropped],
);
const handleLeave = useCallback(
(event: DragEvent<HTMLElement>) => {
if (disabled) return;
if (!Array.from(event.dataTransfer.types).includes("Files")) return;
event.preventDefault();
event.stopPropagation();
depthRef.current = Math.max(0, depthRef.current - 1);
if (depthRef.current === 0) reset();
},
[disabled, reset],
);
const handleDrop = useCallback(
(event: DragEvent<HTMLElement>) => {
if (disabled) return;
if (!Array.from(event.dataTransfer.types).includes("Files")) return;
event.preventDefault();
event.stopPropagation();
const dropped = Array.from(event.dataTransfer.files || []);
reset();
if (!dropped.length) return;
onChange(mergeSelectedFiles(files, dropped));
},
[disabled, files, onChange, reset],
);
const removeFile = useCallback(
(id: string) => {
onChange(files.filter((file) => selectionFileId(file) !== id));
},
[files, onChange],
);
const clearAll = useCallback(() => {
onChange([]);
if (inputRef.current) inputRef.current.value = "";
}, [onChange]);
const padding = compact ? "px-4 py-5" : "px-5 py-7";
return (
<div className="space-y-3">
<button
type="button"
onClick={() => inputRef.current?.click()}
onDragEnter={handleEnter}
onDragLeave={handleLeave}
onDragOver={handleOver}
onDrop={handleDrop}
disabled={disabled}
className={`group flex w-full flex-col items-center justify-center gap-2 rounded-lg border border-dashed text-center transition-colors ${padding} ${
dropState.active
? dropState.invalid
? "border-amber-400 bg-amber-50/60 dark:border-amber-700 dark:bg-amber-950/20"
: "border-sky-400 bg-sky-50/60 dark:border-sky-700 dark:bg-sky-950/20"
: "border-[var(--border)] bg-[var(--background)] hover:border-[var(--foreground)]/25 hover:bg-[var(--muted)]/40"
} ${disabled ? "cursor-not-allowed opacity-50" : ""}`}
>
<Files className="h-5 w-5 text-[var(--muted-foreground)] transition-colors group-hover:text-[var(--foreground)]" />
<div className="space-y-1">
<div className="text-[13px] font-medium text-[var(--foreground)]">
{dropState.active
? dropState.invalid
? t("Some dragged files are not supported")
: t("Drop files to add them")
: files.length
? selection.invalidFiles.length > 0
? t("{{count}} invalid files", {
count: selection.invalidFiles.length,
})
: t("{{count}} files ready", {
count: selection.validFiles.length,
})
: t("Choose files...")}
</div>
<p className="text-[11px] text-[var(--muted-foreground)]">
{dropState.active
? dropState.draggedCount > 0
? t("{{count}} files detected", {
count: dropState.draggedCount,
})
: t("Release to attach the files")
: files.length
? formatFileSize(selection.totalBytes)
: t("Click to browse supported documents")}
</p>
</div>
</button>
{!disabled && (
<button
type="button"
onClick={() => dirInputRef.current?.click()}
className="text-[11px] font-medium text-[var(--muted-foreground)] underline-offset-2 transition-colors hover:text-[var(--foreground)] hover:underline"
>
{t("Or select an entire folder")}
</button>
)}
{!hidePolicyHint && (
<p className="text-[11px] text-[var(--muted-foreground)]">
{uploadPolicy.extensions.length} {t("types")} ·{" "}
{t("Maximum file size: {{size}}", {
size: formatFileSize(uploadPolicy.max_file_size_bytes),
})}
</p>
)}
<input
ref={inputRef}
type="file"
multiple
className="hidden"
accept={uploadPolicy.accept}
onChange={(event) => {
const picked = Array.from(event.target.files || []);
event.target.value = "";
onChange(mergeSelectedFiles(files, picked));
}}
/>
{/* Directory picker — files carry webkitRelativePath so the upload keeps
the folder structure. Unsupported members are flagged, not blocked. */}
<input
ref={setDirInput}
type="file"
multiple
className="hidden"
onChange={(event) => {
const picked = Array.from(event.target.files || []);
event.target.value = "";
onChange(mergeSelectedFiles(files, picked));
}}
/>
{selection.items.length > 0 && (
<SelectionSummary
selection={selection}
onRemove={removeFile}
onClear={clearAll}
/>
)}
</div>
);
}
function SelectionSummary({
selection,
onRemove,
onClear,
}: {
selection: ValidatedFileSelection;
onRemove: (id: string) => void;
onClear: () => void;
}) {
const { t } = useTranslation();
const invalidCount = selection.invalidFiles.length;
const readyCount = selection.validFiles.length;
const hasIssues = invalidCount > 0;
return (
<div
className={`rounded-2xl border p-3 ${
hasIssues
? "border-amber-200 bg-amber-50/80 dark:border-amber-900/70 dark:bg-amber-950/20"
: "border-emerald-200 bg-emerald-50/70 dark:border-emerald-900/60 dark:bg-emerald-950/15"
}`}
>
<div className="flex items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2 text-[13px] font-medium text-[var(--foreground)]">
{hasIssues ? (
<AlertTriangle className="h-4 w-4 text-amber-600 dark:text-amber-400" />
) : (
<CheckCircle2 className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
)}
{hasIssues
? t("{{ready}} ready, {{skip}} will be skipped", {
ready: readyCount,
skip: invalidCount,
})
: t("{{count}} files ready", { count: readyCount })}
</div>
<p className="mt-1 text-[11px] text-[var(--muted-foreground)]">
{hasIssues
? t("Unsupported files are skipped; the rest will be indexed.")
: t("Ready to upload")}{" "}
· {formatFileSize(selection.totalBytes)}
</p>
</div>
<button
type="button"
onClick={onClear}
className="rounded-md border border-[var(--border)] px-2 py-1 text-[11px] text-[var(--muted-foreground)] transition-colors hover:bg-[var(--background)] hover:text-[var(--foreground)]"
>
{t("Clear selection")}
</button>
</div>
<div className="mt-3 space-y-2">
{selection.items.map((item) => (
<div
key={item.id}
className={`flex items-start gap-3 rounded-xl border px-3 py-2.5 ${
item.valid
? "border-white/60 bg-white/70 dark:border-white/10 dark:bg-white/5"
: "border-amber-200/80 bg-amber-100/60 dark:border-amber-900/60 dark:bg-amber-950/20"
}`}
>
<div
className={`mt-0.5 rounded-lg p-2 ${
item.valid
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300"
: "bg-amber-100 text-amber-700 dark:bg-amber-950/30 dark:text-amber-300"
}`}
>
<FileText className="h-3.5 w-3.5" />
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-[12px] font-medium text-[var(--foreground)]">
{item.file.name}
</div>
<div className="mt-1 flex flex-wrap items-center gap-2 text-[10px] uppercase tracking-[0.12em] text-[var(--muted-foreground)]">
<span>{item.extension}</span>
<span>{item.sizeLabel}</span>
<span
className={`rounded-full px-2 py-0.5 normal-case tracking-normal ${
item.valid
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300"
: "bg-amber-100 text-amber-700 dark:bg-amber-950/30 dark:text-amber-300"
}`}
>
{item.valid ? t("Supported") : t("Needs attention")}
</span>
</div>
{item.error && (
<p className="mt-1.5 text-[11px] leading-relaxed text-amber-700 dark:text-amber-300">
{item.error}
</p>
)}
</div>
<button
type="button"
onClick={() => onRemove(item.id)}
title={t("Remove")}
className="rounded-md p-1.5 text-[var(--muted-foreground)] transition-colors hover:bg-[var(--background)] hover:text-[var(--foreground)]"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
))}
</div>
</div>
);
}