Co-authored-by: ConnorYoh <[email protected]>
Co-authored-by: Connor Yoh <[email protected]>
Co-authored-by: EthanHealy01 <[email protected]>
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
This commit is contained in:
Anthony Stirling
2026-03-25 11:00:40 +00:00
committed by GitHub
co-authored by ConnorYoh Connor Yoh EthanHealy01 Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
parent 47cad0a131
commit 28613caf8a
181 changed files with 25715 additions and 124 deletions
@@ -34,6 +34,13 @@ const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
const { t } = useTranslation();
const hasSelection = selectedFiles.length > 0;
const hasMultipleFiles = numberOfFiles > 1;
const showOwner = Boolean(
currentFile &&
(currentFile.remoteOwnedByCurrentUser === false || currentFile.remoteSharedViaLink)
);
const ownerLabel = currentFile
? currentFile.remoteOwnerUsername || t('fileManager.ownerUnknown', 'Unknown')
: '';
return (
<Stack gap="xs" style={{ height: '100%' }}>
@@ -88,6 +95,11 @@ const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
{currentFile.toolHistory.map((tool) => t(`home.${tool.toolId}.title`, tool.toolId)).join(' → ')}
</Text>
)}
{currentFile && showOwner && (
<Text size="xs" c="dimmed">
{t('fileManager.owner', 'Owner')}: {ownerLabel}
</Text>
)}
</Box>
{/* Navigation arrows for multiple files */}
@@ -1,25 +1,68 @@
import React from "react";
import { Group, Text, ActionIcon, Tooltip } from "@mantine/core";
import React, { useEffect } from "react";
import { Group, Text, ActionIcon, Tooltip, SegmentedControl } from "@mantine/core";
import SelectAllIcon from "@mui/icons-material/SelectAll";
import DeleteIcon from "@mui/icons-material/Delete";
import CloudUploadIcon from "@mui/icons-material/CloudUpload";
import LinkIcon from "@mui/icons-material/Link";
import { useTranslation } from "react-i18next";
import { useFileManagerContext } from "@app/contexts/FileManagerContext";
import { useFileActionTerminology } from "@app/hooks/useFileActionTerminology";
import { useFileActionIcons } from "@app/hooks/useFileActionIcons";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import BulkUploadToServerModal from "@app/components/shared/BulkUploadToServerModal";
import BulkShareModal from "@app/components/shared/BulkShareModal";
const FileActions: React.FC = () => {
const { t } = useTranslation();
const terminology = useFileActionTerminology();
const icons = useFileActionIcons();
const DownloadIcon = icons.download;
const { config } = useAppConfig();
const [showBulkUploadModal, setShowBulkUploadModal] = React.useState(false);
const [showBulkShareModal, setShowBulkShareModal] = React.useState(false);
const {
recentFiles,
selectedFileIds,
selectedFiles,
filteredFiles,
onSelectAll,
onDeleteSelected,
onDownloadSelected
} = useFileManagerContext();
onDownloadSelected,
refreshRecentFiles,
storageFilter,
onStorageFilterChange
} =
useFileManagerContext();
const uploadEnabled = config?.storageEnabled === true;
const sharingEnabled = uploadEnabled && config?.storageSharingEnabled === true;
const shareLinksEnabled = sharingEnabled && config?.storageShareLinksEnabled === true;
const showStorageFilter = uploadEnabled;
const storageFilterOptions = sharingEnabled
? [
{ value: "all", label: t("fileManager.filterAll", "All") },
{ value: "local", label: t("fileManager.filterLocal", "Local") },
{ value: "sharedWithMe", label: t("fileManager.filterSharedWithMe", "Shared with me") },
{ value: "sharedByMe", label: t("fileManager.filterSharedByMe", "Shared by me") }
]
: [
{ value: "all", label: t("fileManager.filterAll", "All") },
{ value: "local", label: t("fileManager.filterLocal", "Local") }
];
useEffect(() => {
if (!sharingEnabled && (storageFilter === "sharedWithMe" || storageFilter === "sharedByMe")) {
onStorageFilterChange("all");
}
}, [sharingEnabled, storageFilter, onStorageFilterChange]);
const hasSelection = selectedFileIds.length > 0;
const hasOnlyOwnedSelection = selectedFiles.every((file) => file.remoteOwnedByCurrentUser !== false);
const hasDownloadAccess = selectedFiles.every((file) => {
const role = (file.remoteOwnedByCurrentUser !== false
? 'editor'
: (file.remoteAccessRole ?? 'viewer')).toLowerCase();
return role === 'editor' || role === 'commenter' || role === 'viewer';
});
const canBulkUpload = uploadEnabled && hasSelection && hasOnlyOwnedSelection;
const canBulkShare = shareLinksEnabled && hasSelection && hasOnlyOwnedSelection;
const handleSelectAll = () => {
onSelectAll();
@@ -44,7 +87,6 @@ const FileActions: React.FC = () => {
}
const allFilesSelected = filteredFiles.length > 0 && selectedFileIds.length === filteredFiles.length;
const hasSelection = selectedFileIds.length > 0;
return (
<div
@@ -58,8 +100,8 @@ const FileActions: React.FC = () => {
position: "relative",
}}
>
{/* Left: Select All */}
<div>
{/* Left: Select All + Filter */}
<Group gap="xs">
<Tooltip
label={allFilesSelected ? t("fileManager.deselectAll", "Deselect All") : t("fileManager.selectAll", "Select All")}
>
@@ -74,7 +116,17 @@ const FileActions: React.FC = () => {
<SelectAllIcon style={{ fontSize: "1rem" }} />
</ActionIcon>
</Tooltip>
</div>
{showStorageFilter && (
<SegmentedControl
size="xs"
value={storageFilter}
onChange={(value) =>
onStorageFilterChange(value as "all" | "local" | "sharedWithMe" | "sharedByMe")
}
data={storageFilterOptions}
/>
)}
</Group>
{/* Center: Selected count */}
<div
@@ -93,6 +145,34 @@ const FileActions: React.FC = () => {
{/* Right: Delete and Download */}
<Group gap="xs">
{uploadEnabled && (
<Tooltip label={t("fileManager.uploadSelected", "Upload Selected")}>
<ActionIcon
variant="light"
size="sm"
color="dimmed"
onClick={() => setShowBulkUploadModal(true)}
disabled={!canBulkUpload}
radius="sm"
>
<CloudUploadIcon style={{ fontSize: "1rem" }} />
</ActionIcon>
</Tooltip>
)}
{shareLinksEnabled && (
<Tooltip label={t("fileManager.shareSelected", "Share Selected")}>
<ActionIcon
variant="light"
size="sm"
color="dimmed"
onClick={() => setShowBulkShareModal(true)}
disabled={!canBulkShare}
radius="sm"
>
<LinkIcon style={{ fontSize: "1rem" }} />
</ActionIcon>
</Tooltip>
)}
<Tooltip label={t("fileManager.deleteSelected", "Delete Selected")}>
<ActionIcon
variant="light"
@@ -112,13 +192,30 @@ const FileActions: React.FC = () => {
size="sm"
color="dimmed"
onClick={handleDownloadSelected}
disabled={!hasSelection}
disabled={!hasSelection || !hasDownloadAccess}
radius="sm"
>
<DownloadIcon style={{ fontSize: "1rem" }} />
</ActionIcon>
</Tooltip>
</Group>
{uploadEnabled && (
<BulkUploadToServerModal
opened={showBulkUploadModal}
onClose={() => setShowBulkUploadModal(false)}
files={selectedFiles}
onUploaded={refreshRecentFiles}
/>
)}
{shareLinksEnabled && (
<BulkShareModal
opened={showBulkShareModal}
onClose={() => setShowBulkShareModal(false)}
files={selectedFiles}
onShared={refreshRecentFiles}
/>
)}
</div>
);
};
@@ -1,10 +1,13 @@
import React from 'react';
import { Stack, Card, Box, Text, Badge, Group, Divider, ScrollArea } from '@mantine/core';
import React, { useMemo, useState } from 'react';
import { Stack, Card, Box, Text, Badge, Group, Divider, ScrollArea, Button } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { detectFileExtension, getFileSize } from '@app/utils/fileUtils';
import { StirlingFileStub } from '@app/types/fileContext';
import ToolChain from '@app/components/shared/ToolChain';
import { PrivateContent } from '@app/components/shared/PrivateContent';
import { useFileManagerContext } from '@app/contexts/FileManagerContext';
import ShareManagementModal from '@app/components/shared/ShareManagementModal';
import { useAppConfig } from '@app/contexts/AppConfigContext';
interface FileInfoCardProps {
currentFile: StirlingFileStub | null;
@@ -16,6 +19,40 @@ const FileInfoCard: React.FC<FileInfoCardProps> = ({
modalHeight
}) => {
const { t } = useTranslation();
const { config } = useAppConfig();
const { onMakeCopy } = useFileManagerContext();
const [showShareManageModal, setShowShareManageModal] = useState(false);
const isSharedWithYou = useMemo(() => {
if (!currentFile) return false;
return currentFile.remoteOwnedByCurrentUser === false || currentFile.remoteSharedViaLink;
}, [currentFile]);
const isOwnedRemote = useMemo(() => {
if (!currentFile) return false;
return Boolean(currentFile.remoteStorageId) && currentFile.remoteOwnedByCurrentUser !== false;
}, [currentFile]);
const localUpdatedAt = currentFile?.createdAt ?? currentFile?.lastModified ?? 0;
const remoteUpdatedAt = currentFile?.remoteStorageUpdatedAt ?? 0;
const isUploaded = Boolean(currentFile?.remoteStorageId);
const isUpToDate = isUploaded && remoteUpdatedAt >= localUpdatedAt;
const isOutOfSync = isUploaded && !isUpToDate && isOwnedRemote;
const isLocalOnly = !currentFile?.remoteStorageId && !currentFile?.remoteSharedViaLink;
const isSharedByYou = useMemo(() => {
if (!currentFile) return false;
return isOwnedRemote && Boolean(currentFile.remoteHasShareLinks);
}, [currentFile, isOwnedRemote]);
const uploadEnabled = config?.storageEnabled === true;
const sharingEnabled = uploadEnabled && config?.storageSharingEnabled === true;
const ownerLabel = useMemo(() => {
if (!currentFile) return '';
if (currentFile.remoteOwnerUsername) {
return currentFile.remoteOwnerUsername;
}
return t('fileManager.ownerUnknown', 'Unknown');
}, [currentFile, t]);
const lastSyncedLabel = useMemo(() => {
if (!currentFile?.remoteStorageUpdatedAt) return '';
return new Date(currentFile.remoteStorageUpdatedAt).toLocaleString();
}, [currentFile?.remoteStorageUpdatedAt]);
return (
<Card withBorder p={0} mah={`calc(${modalHeight} * 0.45)`} style={{ overflow: 'hidden', flexShrink: 1, display: 'flex', flexDirection: 'column' }}>
@@ -57,7 +94,7 @@ const FileInfoCard: React.FC<FileInfoCardProps> = ({
<Divider />
<Group justify="space-between" py="xs">
<Text size="sm" c="dimmed">{t('fileManager.lastModified', 'Last Modified')}</Text>
<Text size="sm" c="dimmed">{t('fileManager.lastModified', 'Last modified')}</Text>
<Text size="sm" fw={500}>
{currentFile ? new Date(currentFile.lastModified).toLocaleDateString() : ''}
</Text>
@@ -72,6 +109,20 @@ const FileInfoCard: React.FC<FileInfoCardProps> = ({
</Badge>}
</Group>
{sharingEnabled && isSharedWithYou && (
<>
<Divider />
<Group justify="space-between" py="xs">
<Text size="sm" c="dimmed">{t('fileManager.owner', 'Owner')}</Text>
<Group gap="xs">
<Text size="sm" fw={500}>{ownerLabel}</Text>
<Badge size="xs" variant="light" color="grape">
{t('fileManager.sharedWithYou', 'Shared with you')}
</Badge>
</Group>
</Group>
</>
)}
{/* Tool Chain Display */}
{currentFile?.toolHistory && currentFile.toolHistory.length > 0 && (
@@ -87,8 +138,83 @@ const FileInfoCard: React.FC<FileInfoCardProps> = ({
</Box>
</>
)}
{currentFile && isSharedWithYou && (
<>
<Divider />
<Button
size="sm"
variant="light"
onClick={() => onMakeCopy(currentFile)}
fullWidth
>
{t('fileManager.makeCopy', 'Make a copy')}
</Button>
</>
)}
{currentFile && isOwnedRemote && (
<>
<Divider />
<Group justify="space-between" py="xs">
<Text size="sm" c="dimmed">{t('fileManager.cloudFile', 'Cloud file')}</Text>
{uploadEnabled && isOutOfSync ? (
<Badge size="xs" variant="light" color="yellow">
{t('fileManager.changesNotUploaded', 'Changes not uploaded')}
</Badge>
) : uploadEnabled ? (
<Badge size="xs" variant="light" color="teal">
{t('fileManager.synced', 'Synced')}
</Badge>
) : null}
</Group>
{lastSyncedLabel && (
<Group justify="space-between" py="xs">
<Text size="sm" c="dimmed">{t('fileManager.lastSynced', 'Last synced')}</Text>
<Text size="sm" fw={500}>{lastSyncedLabel}</Text>
</Group>
)}
{isSharedByYou && sharingEnabled && (
<>
<Divider />
<Group justify="space-between" py="xs">
<Text size="sm" c="dimmed">{t('fileManager.sharing', 'Sharing')}</Text>
<Badge size="xs" variant="light" color="blue">
{t('fileManager.sharedByYou', 'Shared by you')}
</Badge>
</Group>
<Button
size="sm"
variant="light"
onClick={() => setShowShareManageModal(true)}
fullWidth
>
{t('storageShare.manage', 'Manage sharing')}
</Button>
</>
)}
</>
)}
{currentFile && isLocalOnly && (
<>
<Divider />
<Group justify="space-between" py="xs">
<Text size="sm" c="dimmed">{t('fileManager.storageState', 'Storage')}</Text>
<Badge size="xs" variant="light" color="gray">
{t('fileManager.localOnly', 'Local only')}
</Badge>
</Group>
</>
)}
</Stack>
</ScrollArea>
{currentFile && isOwnedRemote && isSharedByYou && sharingEnabled && (
<ShareManagementModal
opened={showShareManageModal}
onClose={() => setShowShareManageModal(false)}
file={currentFile}
/>
)}
</Card>
);
};
@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useCallback, useMemo, useState } from 'react';
import { Group, Box, Text, ActionIcon, Checkbox, Divider, Menu, Badge } from '@mantine/core';
import MoreVertIcon from '@mui/icons-material/MoreVert';
import DeleteIcon from '@mui/icons-material/Delete';
@@ -7,6 +7,9 @@ import HistoryIcon from '@mui/icons-material/History';
import RestoreIcon from '@mui/icons-material/Restore';
import UnarchiveIcon from '@mui/icons-material/Unarchive';
import CloseIcon from '@mui/icons-material/Close';
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
import CloudDoneIcon from '@mui/icons-material/CloudDone';
import LinkIcon from '@mui/icons-material/Link';
import { useTranslation } from 'react-i18next';
import { getFileSize, getFileDate } from '@app/utils/fileUtils';
import { FileId, StirlingFileStub } from '@app/types/fileContext';
@@ -16,6 +19,13 @@ import ToolChain from '@app/components/shared/ToolChain';
import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from '@app/styles/zIndex';
import { PrivateContent } from '@app/components/shared/PrivateContent';
import { useFileManagement } from '@app/contexts/FileContext';
import UploadToServerModal from '@app/components/shared/UploadToServerModal';
import ShareFileModal from '@app/components/shared/ShareFileModal';
import { useAppConfig } from '@app/contexts/AppConfigContext';
import ShareManagementModal from '@app/components/shared/ShareManagementModal';
import apiClient from '@app/services/apiClient';
import { absoluteWithBasePath } from '@app/constants/app';
import { alert } from '@app/components/toast';
interface FileListItemProps {
file: StirlingFileStub;
@@ -45,8 +55,12 @@ const FileListItem: React.FC<FileListItemProps> = ({
}) => {
const [isHovered, setIsHovered] = useState(false);
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [showUploadModal, setShowUploadModal] = useState(false);
const [showShareModal, setShowShareModal] = useState(false);
const [showShareManageModal, setShowShareManageModal] = useState(false);
const { t } = useTranslation();
const { expandedFileIds, onToggleExpansion, onUnzipFile } = useFileManagerContext();
const { config } = useAppConfig();
const {expandedFileIds, onToggleExpansion, onUnzipFile, refreshRecentFiles } = useFileManagerContext();
const { removeFiles } = useFileManagement();
// Check if this is a ZIP file
@@ -65,6 +79,73 @@ const FileListItem: React.FC<FileListItemProps> = ({
const hasVersionHistory = (file.versionNumber || 1) > 1; // Show history for any processed file (v2+)
const currentVersion = file.versionNumber || 1; // Display original files as v1
const isExpanded = expandedFileIds.has(leafFileId);
const uploadEnabled = config?.storageEnabled === true;
const sharingEnabled = uploadEnabled && config?.storageSharingEnabled === true;
const shareLinksEnabled = sharingEnabled && config?.storageShareLinksEnabled === true;
const isOwnedOrLocal = file.remoteOwnedByCurrentUser !== false;
const isSharedWithYou =
sharingEnabled && (file.remoteOwnedByCurrentUser === false || file.remoteSharedViaLink);
const localUpdatedAt = file.createdAt ?? file.lastModified ?? 0;
const remoteUpdatedAt = file.remoteStorageUpdatedAt ?? 0;
const isUploaded = Boolean(file.remoteStorageId);
const isUpToDate = isUploaded && remoteUpdatedAt >= localUpdatedAt;
const isOutOfSync = isUploaded && !isUpToDate && isOwnedOrLocal;
const isLocalOnly = !file.remoteStorageId && !file.remoteSharedViaLink;
const accessRole = (isOwnedOrLocal ? 'editor' : (file.remoteAccessRole ?? 'viewer')).toLowerCase();
const hasReadAccess = isOwnedOrLocal || accessRole === 'editor' || accessRole === 'commenter' || accessRole === 'viewer';
const canUpload = uploadEnabled && isOwnedOrLocal && isLatestVersion && (!isUploaded || !isUpToDate);
const canShare = shareLinksEnabled && isOwnedOrLocal && isLatestVersion;
const canManageShare = sharingEnabled && isOwnedOrLocal && Boolean(file.remoteStorageId);
const canCopyShareLink =
shareLinksEnabled && Boolean(file.remoteHasShareLinks) && Boolean(file.remoteStorageId);
const canDownloadFile = Boolean(onDownload) && hasReadAccess;
const shareBaseUrl = useMemo(() => {
const frontendUrl = (config?.frontendUrl || '').trim();
if (frontendUrl) {
const normalized = frontendUrl.endsWith('/')
? frontendUrl.slice(0, -1)
: frontendUrl;
return `${normalized}/share/`;
}
return absoluteWithBasePath('/share/');
}, [config?.frontendUrl]);
const handleCopyShareLink = useCallback(async () => {
if (!file.remoteStorageId) return;
try {
const response = await apiClient.get<{ shareLinks?: Array<{ token?: string }> }>(
`/api/v1/storage/files/${file.remoteStorageId}`,
{ suppressErrorToast: true } as any
);
const links = response.data?.shareLinks ?? [];
const token = links[links.length - 1]?.token;
if (!token) {
alert({
alertType: 'warning',
title: t('storageShare.noLinks', 'No active share links yet.'),
expandable: false,
durationMs: 2500,
});
return;
}
await navigator.clipboard.writeText(`${shareBaseUrl}${token}`);
alert({
alertType: 'success',
title: t('storageShare.copied', 'Link copied to clipboard'),
expandable: false,
durationMs: 2000,
});
} catch (error) {
console.error('Failed to copy share link:', error);
alert({
alertType: 'warning',
title: t('storageShare.copyFailed', 'Copy failed'),
expandable: false,
durationMs: 2500,
});
}
}, [file.remoteStorageId, shareBaseUrl, t]);
return (
<>
@@ -133,6 +214,45 @@ const FileListItem: React.FC<FileListItemProps> = ({
<Badge size="xs" variant="light" color={"blue"}>
v{currentVersion}
</Badge>
{sharingEnabled && isSharedWithYou ? (
<Badge size="xs" variant="light" color="grape">
{t('fileManager.sharedWithYou', 'Shared with you')}
</Badge>
) : null}
{sharingEnabled && isSharedWithYou && accessRole && accessRole !== 'editor' ? (
<Badge size="xs" variant="light" color="gray">
{accessRole === 'commenter'
? t('storageShare.roleCommenter', 'Commenter')
: t('storageShare.roleViewer', 'Viewer')}
</Badge>
) : isLocalOnly ? (
<Badge size="xs" variant="light" color="gray">
{t('fileManager.localOnly', 'Local only')}
</Badge>
) : uploadEnabled && isOutOfSync ? (
<Badge
size="xs"
variant="light"
color="yellow"
leftSection={<CloudUploadIcon style={{ fontSize: 12 }} />}
>
{t('fileManager.changesNotUploaded', 'Changes not uploaded')}
</Badge>
) : uploadEnabled && isUploaded ? (
<Badge
size="xs"
variant="light"
color="teal"
leftSection={<CloudDoneIcon style={{ fontSize: 12 }} />}
>
{t('fileManager.synced', 'Synced')}
</Badge>
) : null}
{sharingEnabled && file.remoteOwnedByCurrentUser !== false && file.remoteHasShareLinks && (
<Badge size="xs" variant="light" color="blue">
{t('fileManager.sharedByYou', 'Shared by you')}
</Badge>
)}
</Group>
<Group gap="xs" align="center">
@@ -194,18 +314,68 @@ const FileListItem: React.FC<FileListItemProps> = ({
</>
)}
{onDownload && (
{canDownloadFile && (
<Menu.Item
leftSection={<DownloadIcon style={{ fontSize: 16 }} />}
onClick={(e) => {
e.stopPropagation();
onDownload();
onDownload?.();
}}
>
{t('fileManager.download', 'Download')}
</Menu.Item>
)}
{canUpload && (
<Menu.Item
leftSection={<CloudUploadIcon style={{ fontSize: 16 }} />}
onClick={(e) => {
e.stopPropagation();
setShowUploadModal(true);
}}
>
{isUploaded
? t('fileManager.updateOnServer', 'Update on Server')
: t('fileManager.uploadToServer', 'Upload to Server')}
</Menu.Item>
)}
{canShare && (
<Menu.Item
leftSection={<LinkIcon style={{ fontSize: 16 }} />}
onClick={(e) => {
e.stopPropagation();
setShowShareModal(true);
}}
>
{t('fileManager.share', 'Share')}
</Menu.Item>
)}
{canCopyShareLink && (
<Menu.Item
leftSection={<LinkIcon style={{ fontSize: 16 }} />}
onClick={(e) => {
e.stopPropagation();
void handleCopyShareLink();
}}
>
{t('storageShare.copyLink', 'Copy share link')}
</Menu.Item>
)}
{canManageShare && (
<Menu.Item
leftSection={<LinkIcon style={{ fontSize: 16 }} />}
onClick={(e) => {
e.stopPropagation();
setShowShareManageModal(true);
}}
>
{t('storageShare.manage', 'Manage sharing')}
</Menu.Item>
)}
{/* Show/Hide History option for latest version files */}
{isLatestVersion && hasVersionHistory && (
<>
@@ -275,6 +445,29 @@ const FileListItem: React.FC<FileListItemProps> = ({
</Group>
</Box>
{ <Divider color="var(--mantine-color-gray-3)" />}
{canUpload && (
<UploadToServerModal
opened={showUploadModal}
onClose={() => setShowUploadModal(false)}
file={file}
onUploaded={refreshRecentFiles}
/>
)}
{canShare && (
<ShareFileModal
opened={showShareModal}
onClose={() => setShowShareModal(false)}
file={file}
onUploaded={refreshRecentFiles}
/>
)}
{canManageShare && (
<ShareManagementModal
opened={showShareManageModal}
onClose={() => setShowShareManageModal(false)}
file={file}
/>
)}
</>
);
};