Add safe archive preview and extraction workflows
This commit is contained in:
+5
-5
@@ -18,12 +18,12 @@
|
||||
"test:file-property-filters": "node scripts/test-file-property-filters-structure.mjs"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"vite": "^6.0.6",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
"vite": "^7.3.6",
|
||||
"typescript": "^5.7.2",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": ">=7.18.2 <8",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
"react-router": ">=8.3.0 <9",
|
||||
"lucide-react": "^1.23.0",
|
||||
"@govoplan/core-webui": "^0.1.9"
|
||||
},
|
||||
|
||||
+87
-2
@@ -328,6 +328,25 @@ export type ManagedFileSnapshotResponse = {files: ManagedFile[];folders: FileFol
|
||||
export type FileSpacesResponse = {spaces: FileSpace[];};
|
||||
export type FileUploadResponse = {files: ManagedFile[];};
|
||||
export type FileUploadProgress = {loaded: number;total?: number;percentage: number | null;};
|
||||
export type ArchivePreviewEntry = {
|
||||
path: string;
|
||||
kind: "file" | "directory";
|
||||
size_bytes: number;
|
||||
compressed_size_bytes?: number | null;
|
||||
encrypted: boolean;
|
||||
};
|
||||
export type ArchivePreviewResponse = {
|
||||
preview_token: string;
|
||||
archive_format: string;
|
||||
entries: ArchivePreviewEntry[];
|
||||
file_count: number;
|
||||
directory_count: number;
|
||||
expanded_size_bytes: number;
|
||||
compressed_size_bytes: number;
|
||||
requires_password: boolean;
|
||||
password_verified: boolean;
|
||||
expires_at: string;
|
||||
};
|
||||
export type FileConnectorFilePayload = {
|
||||
library_id: string;
|
||||
path: string;
|
||||
@@ -556,14 +575,80 @@ options: {
|
||||
return apiFetch<FileUploadResponse>(settings, "/api/v1/files/upload", { method: "POST", body: form });
|
||||
}
|
||||
|
||||
export function previewArchiveUpload(
|
||||
settings: ApiSettings,
|
||||
file: File,
|
||||
options: {
|
||||
owner_type: "user" | "group";
|
||||
owner_id: string;
|
||||
path?: string;
|
||||
campaign_id?: string;
|
||||
password?: string;
|
||||
})
|
||||
: Promise<ArchivePreviewResponse> {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
form.append("owner_type", options.owner_type);
|
||||
form.append("owner_id", options.owner_id);
|
||||
form.append("path", options.path ?? "");
|
||||
if (options.campaign_id) form.append("campaign_id", options.campaign_id);
|
||||
if (options.password) form.append("password", options.password);
|
||||
return apiFetch<ArchivePreviewResponse>(settings, "/api/v1/files/archive-preview", { method: "POST", body: form });
|
||||
}
|
||||
|
||||
export function confirmArchiveUpload(
|
||||
settings: ApiSettings,
|
||||
file: File,
|
||||
options: {
|
||||
preview_token: string;
|
||||
selected_paths: string[];
|
||||
owner_type: "user" | "group";
|
||||
owner_id: string;
|
||||
path?: string;
|
||||
campaign_id?: string;
|
||||
password?: string;
|
||||
conflict_strategy?: ConflictStrategy;
|
||||
conflict_resolutions?: ConflictResolution[];
|
||||
source_provenance?: FileSourceProvenance;
|
||||
source_revision?: string;
|
||||
connector_policy_sources?: FileConnectorPolicySource[];
|
||||
onProgress?: (progress: FileUploadProgress) => void;
|
||||
})
|
||||
: Promise<FileUploadResponse> {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
form.append("preview_token", options.preview_token);
|
||||
form.append("selected_paths_json", JSON.stringify(options.selected_paths));
|
||||
form.append("owner_type", options.owner_type);
|
||||
form.append("owner_id", options.owner_id);
|
||||
form.append("path", options.path ?? "");
|
||||
if (options.campaign_id) form.append("campaign_id", options.campaign_id);
|
||||
if (options.password) form.append("password", options.password);
|
||||
if (options.conflict_strategy) form.append("conflict_strategy", options.conflict_strategy);
|
||||
if (options.conflict_resolutions?.length) form.append("conflict_resolutions_json", JSON.stringify(options.conflict_resolutions));
|
||||
if (options.source_provenance) form.append("source_provenance_json", JSON.stringify(options.source_provenance));
|
||||
if (options.source_revision) form.append("source_revision", options.source_revision);
|
||||
if (options.connector_policy_sources?.length) form.append("connector_policy_json", JSON.stringify({ sources: options.connector_policy_sources }));
|
||||
if (options.onProgress) {
|
||||
return uploadFilesWithProgress(
|
||||
settings,
|
||||
form,
|
||||
options.onProgress,
|
||||
"/api/v1/files/archive-confirm"
|
||||
);
|
||||
}
|
||||
return apiFetch<FileUploadResponse>(settings, "/api/v1/files/archive-confirm", { method: "POST", body: form });
|
||||
}
|
||||
|
||||
function uploadFilesWithProgress(
|
||||
settings: ApiSettings,
|
||||
form: FormData,
|
||||
onProgress: (progress: FileUploadProgress) => void)
|
||||
onProgress: (progress: FileUploadProgress) => void,
|
||||
endpoint = "/api/v1/files/upload")
|
||||
: Promise<FileUploadResponse> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open("POST", apiUrl(settings, "/api/v1/files/upload"));
|
||||
xhr.open("POST", apiUrl(settings, endpoint));
|
||||
xhr.withCredentials = true;
|
||||
for (const [key, value] of authHeaders(settings)) xhr.setRequestHeader(key, value);
|
||||
const csrf = csrfToken();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback } from "react";
|
||||
import { Folder, Network } from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Link } from "react-router";
|
||||
import {
|
||||
DashboardWidgetList,
|
||||
DismissibleAlert,
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
FileDropZone,
|
||||
FormField,
|
||||
LoadingIndicator,
|
||||
PasswordField,
|
||||
ResourceAccessExplanation,
|
||||
ToggleSwitch,
|
||||
hasScope,
|
||||
@@ -20,6 +21,7 @@ import {
|
||||
browseFileConnectorProfile,
|
||||
createFileConnectorSpace,
|
||||
createFolder,
|
||||
confirmArchiveUpload,
|
||||
deleteFolder,
|
||||
downloadFile,
|
||||
downloadFilesAsZip,
|
||||
@@ -29,11 +31,14 @@ import {
|
||||
listFileConnectorProfiles,
|
||||
listFileSpaces,
|
||||
listManagedFileSnapshot,
|
||||
previewArchiveUpload,
|
||||
resolveFilePatterns,
|
||||
syncFileConnectorFile,
|
||||
transferFiles,
|
||||
uploadFiles,
|
||||
virtualFolderResourceId,
|
||||
type ArchivePreviewEntry,
|
||||
type ArchivePreviewResponse,
|
||||
type ConflictResolution,
|
||||
type ConflictStrategy,
|
||||
type FileConnectorBrowseItem,
|
||||
@@ -101,6 +106,7 @@ type FileAccessExplanationTarget = {
|
||||
|
||||
const DEFAULT_FILE_LIST_ROW_HEIGHT = 58;
|
||||
const FILE_LIST_OVERSCAN_ROWS = 8;
|
||||
const ARCHIVE_FILENAME_PATTERN = /\.(?:zip|tar|tar\.gz|tgz|tar\.bz2|tbz2|tar\.xz|txz)$/i;
|
||||
|
||||
export default function FilesPage({ settings, auth }: {settings: ApiSettings;auth: AuthInfo;}) {
|
||||
const canDownload = hasScope(auth, "files:download");
|
||||
@@ -132,6 +138,10 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>("asc");
|
||||
const [unmatchedCount, setUnmatchedCount] = useState<number | null>(null);
|
||||
const [unpackZip, setUnpackZip] = useState(false);
|
||||
const [archiveFile, setArchiveFile] = useState<File | null>(null);
|
||||
const [archivePreview, setArchivePreview] = useState<ArchivePreviewResponse | null>(null);
|
||||
const [archivePassword, setArchivePassword] = useState("");
|
||||
const [selectedArchivePaths, setSelectedArchivePaths] = useState<Set<string>>(new Set());
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [uploadActive, setUploadActive] = useState(false);
|
||||
const [uploadPhase, setUploadPhase] = useState<UploadPhase>("idle");
|
||||
@@ -501,9 +511,19 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
|
||||
setNewFolderName("");
|
||||
setNewFolderError("");
|
||||
}
|
||||
if (kind === "upload") {
|
||||
resetArchiveUploadState();
|
||||
}
|
||||
openRawDialog(kind, target);
|
||||
}
|
||||
|
||||
function resetArchiveUploadState() {
|
||||
setArchiveFile(null);
|
||||
setArchivePreview(null);
|
||||
setArchivePassword("");
|
||||
setSelectedArchivePaths(new Set());
|
||||
}
|
||||
|
||||
function closeDialog() {
|
||||
closeRawDialog();
|
||||
setNewFolderError("");
|
||||
@@ -513,6 +533,7 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
|
||||
setConnectorError("");
|
||||
setConnectorSelectedItem(null);
|
||||
setConnectorSpaceLabel("");
|
||||
resetArchiveUploadState();
|
||||
}
|
||||
|
||||
function updateActiveDialogFolder(spaceId: string, folderPath: string) {
|
||||
@@ -667,6 +688,156 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
|
||||
|
||||
|
||||
|
||||
async function loadArchivePreview(
|
||||
file: File,
|
||||
target: FileActionTarget,
|
||||
options: { preserveSelection?: boolean } = {}
|
||||
) {
|
||||
const targetSpace = findSpace(target.spaceId);
|
||||
if (!targetSpace || isConnectorSpace(targetSpace)) {
|
||||
setError(uploadRejectedReason(target));
|
||||
return;
|
||||
}
|
||||
setArchiveFile(file);
|
||||
setBusy(true);
|
||||
setUploadActive(true);
|
||||
setUploadPhase("uploading");
|
||||
setUploadProgress(null);
|
||||
setError("");
|
||||
setMessage("");
|
||||
try {
|
||||
const response = await previewArchiveUpload(settings, file, {
|
||||
owner_type: targetSpace.owner_type,
|
||||
owner_id: targetSpace.owner_id,
|
||||
path: target.folderPath,
|
||||
password: archivePassword || undefined
|
||||
});
|
||||
const availableFiles = new Set(
|
||||
response.entries
|
||||
.filter((entry) => entry.kind === "file")
|
||||
.map((entry) => entry.path)
|
||||
);
|
||||
setArchivePreview(response);
|
||||
setSelectedArchivePaths((current) => {
|
||||
if (!options.preserveSelection) return availableFiles;
|
||||
const retained = new Set(
|
||||
Array.from(current).filter((path) => availableFiles.has(path))
|
||||
);
|
||||
return retained.size > 0 ? retained : availableFiles;
|
||||
});
|
||||
} catch (err) {
|
||||
setArchivePreview(null);
|
||||
setSelectedArchivePaths(new Set());
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
setUploadActive(false);
|
||||
setUploadPhase("idle");
|
||||
setUploadProgress(null);
|
||||
}
|
||||
}
|
||||
|
||||
function archiveFilesForEntry(entry: ArchivePreviewEntry): string[] {
|
||||
if (!archivePreview) return [];
|
||||
if (entry.kind === "file") return [entry.path];
|
||||
const prefix = `${entry.path}/`;
|
||||
return archivePreview.entries
|
||||
.filter((candidate) => candidate.kind === "file" && candidate.path.startsWith(prefix))
|
||||
.map((candidate) => candidate.path);
|
||||
}
|
||||
|
||||
function archiveEntryIsSelected(entry: ArchivePreviewEntry): boolean {
|
||||
const paths = archiveFilesForEntry(entry);
|
||||
return paths.length > 0 && paths.every((path) => selectedArchivePaths.has(path));
|
||||
}
|
||||
|
||||
function toggleArchiveEntry(entry: ArchivePreviewEntry, selected: boolean) {
|
||||
const paths = archiveFilesForEntry(entry);
|
||||
setSelectedArchivePaths((current) => {
|
||||
const next = new Set(current);
|
||||
paths.forEach((path) => selected ? next.add(path) : next.delete(path));
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function toggleAllArchiveFiles(selected: boolean) {
|
||||
setSelectedArchivePaths(
|
||||
selected && archivePreview
|
||||
? new Set(
|
||||
archivePreview.entries
|
||||
.filter((entry) => entry.kind === "file")
|
||||
.map((entry) => entry.path)
|
||||
)
|
||||
: new Set()
|
||||
);
|
||||
}
|
||||
|
||||
async function confirmCurrentArchive() {
|
||||
const target = currentActionTarget();
|
||||
const targetSpace = target ? findSpace(target.spaceId) : null;
|
||||
if (
|
||||
busy
|
||||
|| !archiveFile
|
||||
|| !archivePreview
|
||||
|| !target
|
||||
|| !targetSpace
|
||||
|| isConnectorSpace(targetSpace)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (selectedArchivePaths.size === 0) {
|
||||
setError("Select at least one archive file to import.");
|
||||
return;
|
||||
}
|
||||
if (archivePreview.requires_password && !archivePassword) {
|
||||
setError("Enter the archive password before importing.");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setUploadActive(true);
|
||||
setUploadPhase("uploading");
|
||||
setUploadProgress(0);
|
||||
setError("");
|
||||
setMessage("Uploading the archive for confirmed extraction.");
|
||||
try {
|
||||
const response = await confirmArchiveUpload(settings, archiveFile, {
|
||||
preview_token: archivePreview.preview_token,
|
||||
selected_paths: Array.from(selectedArchivePaths),
|
||||
owner_type: targetSpace.owner_type,
|
||||
owner_id: targetSpace.owner_id,
|
||||
path: target.folderPath,
|
||||
password: archivePassword || undefined,
|
||||
conflict_strategy: "reject",
|
||||
onProgress: ({ percentage }) => {
|
||||
setUploadProgress(percentage);
|
||||
if (percentage !== null && percentage >= 100) {
|
||||
setUploadPhase("unpacking");
|
||||
setMessage("Extracting the selected archive files.");
|
||||
}
|
||||
}
|
||||
});
|
||||
setUploadPhase("finalizing");
|
||||
setUploadProgress(100);
|
||||
const uploadedCount = response.files.length;
|
||||
const destination = target.folderPath || "i18n:govoplan-files.root.e96857c5";
|
||||
closeDialog();
|
||||
setMessage(i18nMessage("i18n:govoplan-files.uploaded_value_file_s_into_value.d0fa052b", {
|
||||
value0: uploadedCount,
|
||||
value1: destination
|
||||
}));
|
||||
resetTransientState();
|
||||
await loadSpaceContents(targetSpace, { silent: targetSpace.id !== activeSpaceId });
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
setMessage("");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
setUploadActive(false);
|
||||
setUploadPhase("idle");
|
||||
setUploadProgress(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFilesUpload(fileList: FileList | File[], options: {conflictStrategy?: ConflictStrategy;conflictResolutions?: ConflictResolution[];bypassConflictDialog?: boolean;target?: FileActionTarget;} = {}) {
|
||||
const target = options.target ?? currentActionTarget();
|
||||
const targetSpace = target ? findSpace(target.spaceId) : null;
|
||||
@@ -676,6 +847,14 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
|
||||
}
|
||||
const selected = Array.from(fileList);
|
||||
if (selected.length === 0) return;
|
||||
if (unpackZip) {
|
||||
if (selected.length !== 1 || !ARCHIVE_FILENAME_PATTERN.test(selected[0].name)) {
|
||||
setError("Choose one ZIP, TAR, TAR.GZ, TAR.BZ2, or TAR.XZ archive to preview and unpack.");
|
||||
return;
|
||||
}
|
||||
await loadArchivePreview(selected[0], target);
|
||||
return;
|
||||
}
|
||||
if (!options.bypassConflictDialog && !unpackZip) {
|
||||
const conflicts = uploadConflicts(selected, target);
|
||||
if (conflicts.length > 0) {
|
||||
@@ -691,7 +870,6 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
|
||||
return;
|
||||
}
|
||||
}
|
||||
const uploadsZipArchive = unpackZip && selected.some((file) => file.name.toLowerCase().endsWith(".zip"));
|
||||
setBusy(true);
|
||||
setUploadActive(true);
|
||||
setUploadPhase("uploading");
|
||||
@@ -703,14 +881,13 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
|
||||
owner_type: targetSpace.owner_type,
|
||||
owner_id: targetSpace.owner_id,
|
||||
path: target.folderPath,
|
||||
unpack_zip: unpackZip,
|
||||
conflict_strategy: options.conflictStrategy ?? "reject",
|
||||
conflict_resolutions: options.conflictResolutions,
|
||||
onProgress: ({ percentage }) => {
|
||||
setUploadProgress(percentage);
|
||||
if (percentage !== null && percentage >= 100) {
|
||||
setUploadPhase(uploadsZipArchive ? "unpacking" : "finalizing");
|
||||
setMessage(uploadsZipArchive ? "i18n:govoplan-files.unpacking_zip_upload.35019691" : "i18n:govoplan-files.finalizing_upload.bcce936d");
|
||||
setUploadPhase("finalizing");
|
||||
setMessage("i18n:govoplan-files.finalizing_upload.bcce936d");
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -2332,6 +2509,29 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
|
||||
|
||||
{dialog === "upload" &&
|
||||
<FileDialog title={i18nMessage("i18n:govoplan-files.upload_to_value_value.b83a34b4", { value0: activeDialogSpace?.label || "i18n:govoplan-files.files.6ce6c512", value1: activeDialogTarget?.folderPath || "i18n:govoplan-files.root.e96857c5" })} onClose={closeDialog}>
|
||||
{!archivePreview &&
|
||||
<>
|
||||
<ToggleSwitch
|
||||
label="Preview and unpack archive"
|
||||
checked={unpackZip}
|
||||
onChange={setUnpackZip}
|
||||
disabled={busy} />
|
||||
|
||||
{unpackZip &&
|
||||
<p className="form-help archive-upload-help">
|
||||
Supports ZIP, TAR, TAR.GZ, TAR.BZ2, and TAR.XZ. Nothing is written until you review and confirm the archive contents.
|
||||
</p>
|
||||
}
|
||||
<div className="field-block">
|
||||
<FieldLabel className="form-label" help="i18n:govoplan-files.choose_the_destination_folder_before_selecting_o.a4922d75">i18n:govoplan-files.destination_folder.f8ccb63b</FieldLabel>
|
||||
<TransferFolderSelector
|
||||
space={activeDialogSpace}
|
||||
nodes={activeDialogTarget ? buildFolderTree(filesBySpace[activeDialogTarget.spaceId] ?? EMPTY_FILES, foldersBySpace[activeDialogTarget.spaceId] ?? EMPTY_FOLDERS) : []}
|
||||
selectedFolder={activeDialogTarget?.folderPath || ""}
|
||||
disabled={busy || !activeDialogSpace}
|
||||
onSelect={(folderPath) => activeDialogTarget && updateActiveDialogFolder(activeDialogTarget.spaceId, folderPath)} />
|
||||
|
||||
</div>
|
||||
<FileDropZone
|
||||
disabled={busy || !activeDialogTarget || !activeDialogSpace || isConnectorSpace(activeDialogSpace) || !canUpload}
|
||||
busy={uploadActive}
|
||||
@@ -2341,21 +2541,115 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
|
||||
note={`Files are uploaded into ${activeDialogTarget?.folderPath || "i18n:govoplan-files.root.e96857c5"}.`}
|
||||
onRejectedDrop={(reason) => setError(reason === "disabled" ? uploadRejectedReason(activeDialogTarget) : "The browser did not provide readable file data for this drop. Use the file picker, or drag local files from a file manager that exposes file contents to the browser.")}
|
||||
onFiles={(files) => handleFilesUpload(files, { target: activeDialogTarget || undefined })} />
|
||||
|
||||
<ToggleSwitch label="i18n:govoplan-files.unpack_zip_uploads.256fead4" checked={unpackZip} onChange={setUnpackZip} disabled={busy} />
|
||||
<div className="field-block">
|
||||
<FieldLabel className="form-label" help="i18n:govoplan-files.choose_the_destination_folder_before_selecting_o.a4922d75">i18n:govoplan-files.destination_folder.f8ccb63b</FieldLabel>
|
||||
<TransferFolderSelector
|
||||
space={activeDialogSpace}
|
||||
nodes={activeDialogTarget ? buildFolderTree(filesBySpace[activeDialogTarget.spaceId] ?? EMPTY_FILES, foldersBySpace[activeDialogTarget.spaceId] ?? EMPTY_FOLDERS) : []}
|
||||
selectedFolder={activeDialogTarget?.folderPath || ""}
|
||||
disabled={busy || !activeDialogSpace}
|
||||
onSelect={(folderPath) => activeDialogTarget && updateActiveDialogFolder(activeDialogTarget.spaceId, folderPath)} />
|
||||
|
||||
</div>
|
||||
<div className="button-row compact-actions align-end">
|
||||
<Button onClick={closeDialog} disabled={busy}>i18n:govoplan-files.cancel.77dfd213</Button>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
{archivePreview && archiveFile &&
|
||||
<div className="archive-preview">
|
||||
<div className="archive-preview-summary">
|
||||
<div>
|
||||
<strong>{archiveFile.name}</strong>
|
||||
<span>{archivePreview.archive_format.toUpperCase()} · {formatBytes(archivePreview.compressed_size_bytes)} compressed</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{archivePreview.file_count} files</strong>
|
||||
<span>{formatBytes(archivePreview.expanded_size_bytes)} expanded · {archivePreview.directory_count} folders</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Destination</strong>
|
||||
<span>{activeDialogTarget?.folderPath || "i18n:govoplan-files.root.e96857c5"}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{archivePreview.requires_password &&
|
||||
<FormField
|
||||
label="Archive password"
|
||||
help="The password stays in this dialog and is sent only while inspecting or importing this archive.">
|
||||
<PasswordField
|
||||
value={archivePassword}
|
||||
onValueChange={setArchivePassword}
|
||||
disabled={busy}
|
||||
autoComplete="off" />
|
||||
|
||||
</FormField>
|
||||
}
|
||||
|
||||
<div className="archive-selection-toolbar">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedArchivePaths.size === archivePreview.file_count && archivePreview.file_count > 0}
|
||||
onChange={(event) => toggleAllArchiveFiles(event.target.checked)}
|
||||
disabled={busy || archivePreview.file_count === 0} />
|
||||
|
||||
<span>Select all files</span>
|
||||
</label>
|
||||
<span>{selectedArchivePaths.size} of {archivePreview.file_count} selected</span>
|
||||
</div>
|
||||
|
||||
<div className="archive-entry-list" role="list" aria-label="Archive contents">
|
||||
{archivePreview.entries.map((entry) => {
|
||||
const selectableFiles = archiveFilesForEntry(entry);
|
||||
const depth = Math.max(0, entry.path.split("/").length - 1);
|
||||
return (
|
||||
<label
|
||||
key={`${entry.kind}:${entry.path}`}
|
||||
className={`archive-entry-row ${entry.kind === "directory" ? "is-directory" : ""}`}
|
||||
style={{ paddingLeft: `${12 + depth * 18}px` }}>
|
||||
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={archiveEntryIsSelected(entry)}
|
||||
onChange={(event) => toggleArchiveEntry(entry, event.target.checked)}
|
||||
disabled={busy || selectableFiles.length === 0} />
|
||||
|
||||
{entry.kind === "directory" ?
|
||||
<Folder size={16} aria-hidden="true" /> :
|
||||
<File size={16} aria-hidden="true" />
|
||||
}
|
||||
<span className="archive-entry-path">{entry.path.split("/").at(-1)}</span>
|
||||
<span className="archive-entry-size">{entry.kind === "file" ? formatBytes(entry.size_bytes) : `${selectableFiles.length} files`}</span>
|
||||
</label>);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<p className="form-help">
|
||||
Preview expires {formatDate(archivePreview.expires_at)}. The original archive is uploaded again only when you confirm.
|
||||
</p>
|
||||
|
||||
<div className="button-row compact-actions archive-preview-actions">
|
||||
<Button
|
||||
onClick={() => {
|
||||
resetArchiveUploadState();
|
||||
setError("");
|
||||
}}
|
||||
disabled={busy}>
|
||||
Choose another file
|
||||
</Button>
|
||||
{archivePreview.requires_password &&
|
||||
<Button
|
||||
onClick={() => activeDialogTarget && void loadArchivePreview(archiveFile, activeDialogTarget, { preserveSelection: true })}
|
||||
disabled={busy || !archivePassword}>
|
||||
<RefreshCw size={15} aria-hidden="true" /> Verify password
|
||||
</Button>
|
||||
}
|
||||
<span className="archive-preview-action-spacer" />
|
||||
<Button onClick={closeDialog} disabled={busy}>i18n:govoplan-files.cancel.77dfd213</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => void confirmCurrentArchive()}
|
||||
disabled={
|
||||
busy
|
||||
|| selectedArchivePaths.size === 0
|
||||
|| archivePreview.requires_password && !archivePassword
|
||||
}>
|
||||
Import selected
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</FileDialog>
|
||||
}
|
||||
|
||||
|
||||
@@ -288,6 +288,132 @@
|
||||
width: min(820px, 100%);
|
||||
}
|
||||
|
||||
.file-dialog:has(.archive-preview) {
|
||||
width: min(860px, 100%);
|
||||
}
|
||||
|
||||
.archive-upload-help {
|
||||
margin: -8px 0 0;
|
||||
}
|
||||
|
||||
.archive-preview {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.archive-preview-summary {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.3fr) minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 1px;
|
||||
overflow: hidden;
|
||||
border: var(--border-line);
|
||||
border-radius: 6px;
|
||||
background: var(--line);
|
||||
}
|
||||
|
||||
.archive-preview-summary > div {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 4px;
|
||||
padding: 11px 12px;
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
|
||||
.archive-preview-summary strong,
|
||||
.archive-preview-summary span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.archive-preview-summary span {
|
||||
color: var(--muted);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.archive-selection-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.archive-selection-toolbar label,
|
||||
.archive-entry-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.archive-selection-toolbar label {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.archive-selection-toolbar > span,
|
||||
.archive-entry-size {
|
||||
color: var(--muted);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.archive-entry-list {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
max-height: min(380px, 42vh);
|
||||
overflow: auto;
|
||||
scrollbar-gutter: stable;
|
||||
border: var(--border-line);
|
||||
border-radius: 6px;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.archive-entry-row {
|
||||
min-width: 0;
|
||||
min-height: 38px;
|
||||
padding-top: 7px;
|
||||
padding-right: 12px;
|
||||
padding-bottom: 7px;
|
||||
border-bottom: var(--border-line);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.archive-entry-row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.archive-entry-row:hover {
|
||||
background: var(--hover-tint);
|
||||
}
|
||||
|
||||
.archive-entry-row.is-directory {
|
||||
background: var(--panel-soft);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.archive-entry-row.is-directory:hover {
|
||||
background: var(--primary-soft);
|
||||
}
|
||||
|
||||
.archive-entry-path {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.archive-entry-size {
|
||||
flex: 0 0 auto;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.archive-preview-actions {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.archive-preview-action-spacer {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.file-dialog:has(.file-share-dialog-content) {
|
||||
width: min(1080px, 100%);
|
||||
}
|
||||
@@ -312,6 +438,10 @@
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.archive-preview-summary {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.file-share-form {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user