diff --git a/frontend/pages/file-storage/FileDetails.jsx b/frontend/pages/file-storage/FileDetails.jsx
new file mode 100644
index 000000000..fb1c121ae
--- /dev/null
+++ b/frontend/pages/file-storage/FileDetails.jsx
@@ -0,0 +1,105 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { IconAttachment } from '@shared/icons';
+import { dateAndTimeSimple } from '@util/datetime';
+import prettyBytes from 'pretty-bytes';
+
+const FileDetails = ({
+ name,
+ fullPath,
+ updatedBy,
+ updatedAt,
+ size,
+ mimeType,
+ onDelete,
+ onClose,
+}) => {
+ return (
+
+
+
+
File details
+
+
+
+
+
+ Property |
+ Value |
+
+
+
+
+ File name |
+ {name} |
+
+
+ Full path |
+
+
+ {fullPath}
+
+ |
+
+
+ Uploaded by |
+ {updatedBy} |
+
+
+ Uploaded at |
+ {updatedAt && dateAndTimeSimple(updatedAt)} |
+
+
+ File size |
+ {size && prettyBytes(size)} |
+
+
+ MIME type |
+ {mimeType} |
+
+
+ Actions |
+
+
+ Download
+
+
+ |
+
+
+
+
+ );
+};
+
+FileDetails.propTypes = {
+ name: PropTypes.string.isRequired,
+ fullPath: PropTypes.string.isRequired,
+ updatedBy: PropTypes.string.isRequired,
+ updatedAt: PropTypes.string.isRequired,
+ size: PropTypes.number.isRequired,
+ mimeType: PropTypes.string.isRequired,
+ onDelete: PropTypes.func.isRequired,
+};
+
+export default FileDetails;
diff --git a/frontend/pages/file-storage/FileDetails.test.jsx b/frontend/pages/file-storage/FileDetails.test.jsx
new file mode 100644
index 000000000..0b129c4b0
--- /dev/null
+++ b/frontend/pages/file-storage/FileDetails.test.jsx
@@ -0,0 +1,99 @@
+import React from 'react';
+import { render, screen, fireEvent } from '@testing-library/react';
+import '@testing-library/jest-dom';
+import { MemoryRouter } from 'react-router-dom';
+import FileDetails from './FileDetails';
+import prettyBytes from 'pretty-bytes';
+
+jest.mock('pretty-bytes', () => ({
+ __esModule: true,
+ default: jest.fn(),
+}));
+
+describe('FileDetails', () => {
+ const mockOnDelete = jest.fn();
+ const mockOnClose = jest.fn();
+ const fileProps = {
+ name: 'test-document.pdf',
+ fullPath: 'https://example.com/files/test-document.pdf',
+ updatedBy: 'user@example.com',
+ updatedAt: '2025-02-19T12:00:00Z',
+ size: 1048576, // 1MB
+ mimeType: 'application/pdf',
+ onDelete: mockOnDelete,
+ onClose: mockOnClose,
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ prettyBytes.mockReturnValue('1 MB');
+ });
+
+ it('renders file details correctly', () => {
+ render(
+
+
+ ,
+ );
+
+ // File name
+ expect(screen.getByText(fileProps.name)).toBeInTheDocument();
+
+ // Full path (link)
+ expect(screen.getByRole('link', { name: fileProps.fullPath })).toHaveAttribute(
+ 'href',
+ fileProps.fullPath,
+ );
+
+ // Uploaded by
+ expect(screen.getByText(fileProps.updatedBy)).toBeInTheDocument();
+
+ // Uploaded on (formatted date check)
+ expect(screen.getByText(/Feb 19, 2025/i)).toBeInTheDocument();
+
+ // File size
+ expect(screen.getByText('1 MB')).toBeInTheDocument();
+
+ // MIME type
+ expect(screen.getByText(fileProps.mimeType)).toBeInTheDocument();
+ });
+
+ it('calls onClose when the close button is clicked', () => {
+ render(
+
+
+ ,
+ );
+
+ const closeButton = screen.getByTitle('close file details');
+ fireEvent.click(closeButton);
+
+ expect(mockOnClose).toHaveBeenCalledTimes(1);
+ });
+
+ it('calls onDelete when the delete button is clicked', () => {
+ render(
+
+
+ ,
+ );
+
+ const deleteButton = screen.getByRole('button', { name: /delete/i });
+ fireEvent.click(deleteButton);
+
+ expect(mockOnDelete).toHaveBeenCalledTimes(1);
+ expect(mockOnDelete).toHaveBeenCalledWith({ type: 'file', name: fileProps.name });
+ });
+
+ it('renders a working download link', () => {
+ render(
+
+
+ ,
+ );
+
+ const downloadLink = screen.getByRole('link', { name: /download/i });
+ expect(downloadLink).toHaveAttribute('href', fileProps.fullPath);
+ expect(downloadLink).toHaveAttribute('download');
+ });
+});
diff --git a/frontend/pages/file-storage/FileList.jsx b/frontend/pages/file-storage/FileList.jsx
new file mode 100644
index 000000000..2278451f1
--- /dev/null
+++ b/frontend/pages/file-storage/FileList.jsx
@@ -0,0 +1,278 @@
+import React, { useState } from 'react';
+import PropTypes from 'prop-types';
+import { dateAndTimeSimple, timeFrom } from '@util/datetime';
+import { IconAttachment, IconFolder, IconLink } from '@shared/icons';
+
+// constants and functions used by both components
+
+function ariaFormatSort(direction) {
+ if (direction === 'asc') return 'ascending';
+ if (direction === 'desc') return 'descending';
+ return null;
+}
+const SORT_KEY_NAME = 'name';
+const SORT_KEY_LAST_MODIFIED = 'updatedAt';
+
+const FileListRow = ({
+ item,
+ storageRoot,
+ path,
+ currentSortKey,
+ onNavigate,
+ onDelete,
+ onViewDetails,
+}) => {
+ const copyUrl = `${storageRoot}${path}${item.name}`;
+ // handle copying the file's URL
+ const [copySuccess, setCopySuccess] = useState(false);
+ const handleCopy = async (url) => {
+ try {
+ await navigator.clipboard.writeText(url);
+ console.log('copied', copyUrl);
+ setCopySuccess(true);
+ setTimeout(() => setCopySuccess(false), 4000);
+ } catch (err) {
+ console.error('Failed to copy: ', err);
+ }
+ };
+
+ return (
+
+
+
+ |
+
+
+
+ {item.updatedAt ? timeFrom(item.updatedAt) : 'N/A'}
+
+ |
+
+ {item.type !== 'directory' && (
+
+ )}
+
+ |
+
+ );
+};
+
+const FileList = ({
+ path,
+ storageRoot,
+ data,
+ onDelete,
+ onNavigate,
+ onSort,
+ onViewDetails,
+ currentSortKey,
+ currentSortOrder,
+}) => {
+ const EMPTY_STATE_MESSAGE = 'No files or folders found.';
+ const TABLE_CAPTION = `
+ Listing all contents for the current folder, sorted by ${currentSortKey} in
+ ${ariaFormatSort(currentSortOrder)} order
+ `; // TODO: Create and update an aria live region to announce all changes
+
+ return (
+
+ {TABLE_CAPTION}
+
+
+
+ Name
+
+ |
+
+ Updated at
+
+ |
+
+ Actions
+ |
+
+
+
+ {data.map((item) => (
+
+ ))}
+ {data.length === 0 && (
+
+
+ {EMPTY_STATE_MESSAGE}
+ |
+
+ )}
+
+
+ );
+};
+
+const SortIcon = ({ sort = false }) => (
+
+);
+
+FileList.propTypes = {
+ path: PropTypes.string.isRequired,
+ storageRoot: PropTypes.string.isRequired,
+ data: PropTypes.arrayOf(
+ PropTypes.shape({
+ name: PropTypes.string.isRequired,
+ type: PropTypes.string.isRequired,
+ updatedAt: PropTypes.string.isRequired,
+ }),
+ ).isRequired,
+ onDelete: PropTypes.func.isRequired,
+ onNavigate: PropTypes.func.isRequired,
+ onViewDetails: PropTypes.func.isRequired,
+ onSort: PropTypes.func.isRequired,
+ currentSortKey: PropTypes.string.isRequired,
+ currentSortOrder: PropTypes.oneOf(['asc', 'desc']).isRequired,
+};
+
+FileListRow.propTypes = {
+ path: PropTypes.string.isRequired,
+ storageRoot: PropTypes.string.isRequired,
+ item: PropTypes.shape({
+ name: PropTypes.string.isRequired,
+ type: PropTypes.string.isRequired,
+ updatedAt: PropTypes.string.isRequired,
+ }).isRequired,
+ onDelete: PropTypes.func.isRequired,
+ onNavigate: PropTypes.func.isRequired,
+ onViewDetails: PropTypes.func.isRequired,
+ currentSortKey: PropTypes.string.isRequired,
+};
+
+SortIcon.propTypes = {
+ sort: PropTypes.string,
+};
+
+export default FileList;
diff --git a/frontend/pages/file-storage/FileList.test.jsx b/frontend/pages/file-storage/FileList.test.jsx
new file mode 100644
index 000000000..462cbab80
--- /dev/null
+++ b/frontend/pages/file-storage/FileList.test.jsx
@@ -0,0 +1,150 @@
+import React, { act } from 'react';
+import { render, screen, fireEvent } from '@testing-library/react';
+import '@testing-library/jest-dom';
+import { MemoryRouter } from 'react-router-dom';
+import FileList from './FileList.jsx';
+
+const mockFiles = [
+ { name: 'Documents', type: 'directory', updatedAt: '2024-02-10T12:30:00Z' },
+ {
+ name: 'report.pdf',
+ type: 'application/pdf',
+ updatedAt: '2025-01-09T15:45:00Z',
+ updatedBy: 'user@federal.gov',
+ size: 23456,
+ },
+ {
+ name: 'presentation.ppt',
+ type: 'application/vnd.ms-powerpoint',
+ updatedAt: '2024-02-08T09:15:00Z',
+ updatedBy: 'user@federal.gov',
+ size: 23456,
+ },
+];
+
+const mockProps = {
+ path: '/',
+ storageRoot: 'https://custom.domain.gov/~assets',
+ data: mockFiles,
+ onDelete: jest.fn(),
+ onNavigate: jest.fn(),
+ onViewDetails: jest.fn(),
+ onSort: jest.fn(),
+ currentSortKey: 'name',
+ currentSortOrder: 'asc',
+};
+
+describe('FileList', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('renders correctly with file and folder names', () => {
+ render(
+
+
+ ,
+ );
+ expect(screen.getByRole('link', { name: 'Documents' })).toBeInTheDocument();
+ expect(screen.getByRole('link', { name: 'report.pdf' })).toBeInTheDocument();
+ expect(screen.getByRole('link', { name: 'presentation.ppt' })).toBeInTheDocument();
+ });
+
+ it('does not trigger onViewDetails when clicking a folder', () => {
+ render(
+
+
+ ,
+ );
+ fireEvent.click(screen.getByRole('link', { name: 'Documents' }));
+ expect(mockProps.onViewDetails).not.toHaveBeenCalled();
+ });
+
+ it('calls onNavigate when a folder is clicked', () => {
+ render(
+
+
+ ,
+ );
+ fireEvent.click(screen.getByRole('link', { name: 'Documents' }));
+ expect(mockProps.onNavigate).toHaveBeenCalledWith('/Documents/');
+ });
+
+ it('does not trigger onNavigate when clicking a file', () => {
+ render(
+
+
+ ,
+ );
+ fireEvent.click(screen.getByRole('link', { name: 'report.pdf' }));
+ expect(mockProps.onNavigate).not.toHaveBeenCalled();
+ });
+
+ it('calls onViewDetails when a file name is clicked', () => {
+ render(
+
+
+ ,
+ );
+ fireEvent.click(screen.getByRole('link', { name: 'report.pdf' }));
+ expect(mockProps.onViewDetails).toHaveBeenCalledWith('report.pdf');
+ });
+
+ it('calls onSort and updates the sort dir when a sortable header is clicked', () => {
+ render(
+
+
+ ,
+ );
+
+ fireEvent.click(screen.getByLabelText('Sort by name'));
+ expect(mockProps.onSort).toHaveBeenCalledWith('name');
+ expect(screen.getByRole('columnheader', { name: /name/i })).toHaveAttribute(
+ 'aria-sort',
+ 'ascending',
+ );
+ });
+
+ it('calls onDelete when a file delete button is clicked', () => {
+ render(
+
+
+ ,
+ );
+ fireEvent.click(screen.getAllByRole('button', { name: 'Delete' })[0]);
+ expect(mockProps.onDelete).toHaveBeenCalledWith(mockFiles[0]);
+ });
+
+ it('renders empty state message when no files are present', () => {
+ render(
+
+
+ ,
+ );
+ expect(screen.getByText('No files or folders found.')).toBeInTheDocument();
+ });
+
+ it('copies file link to clipboard when copy button is clicked', async () => {
+ Object.assign(navigator, {
+ clipboard: {
+ writeText: jest.fn().mockResolvedValue(),
+ },
+ });
+
+ render(
+
+
+ ,
+ );
+
+ const copyButton = screen.getAllByText('Copy link')[0];
+
+ await act(async () => {
+ fireEvent.click(copyButton);
+ });
+
+ expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
+ 'https://custom.domain.gov/~assets/report.pdf',
+ );
+ });
+});
diff --git a/frontend/pages/file-storage/LocationBar.jsx b/frontend/pages/file-storage/LocationBar.jsx
new file mode 100644
index 000000000..e48b01871
--- /dev/null
+++ b/frontend/pages/file-storage/LocationBar.jsx
@@ -0,0 +1,111 @@
+import React, { useMemo } from 'react';
+import PropTypes from 'prop-types';
+import { Link } from 'react-router-dom';
+import { IconGlobe } from '@shared/icons';
+
+const LocationBar = ({ path, siteId, storageRoot, onNavigate }) => {
+ const baseUrl = `/sites/${siteId}/storage`;
+ const cleanedPath = path.replace(/^\/+/, ''); // Only trim leading slashes, keep trailing
+ const segments = cleanedPath ? cleanedPath.split('/').filter(Boolean) : [];
+
+ const showDomain = segments.length <= 1; // Show storageRoot only if it's the current or parent folder
+ const grandparentPath = segments.slice(0, segments.length - 1).join('/') + '/';
+
+ const displayedBreadcrumbs = useMemo(() => {
+ if (segments.length > 1) {
+ return segments.slice(-2).map((seg) => `${seg}/`); // Ensure it only includes the last two segments
+ }
+ return segments.length === 1 ? [`${segments[0]}/`] : [];
+ }, [segments]);
+
+ return (
+
+ );
+};
+
+LocationBar.propTypes = {
+ path: PropTypes.string.isRequired,
+ siteId: PropTypes.string.isRequired,
+ storageRoot: PropTypes.string.isRequired,
+ onNavigate: PropTypes.func,
+};
+
+export default LocationBar;
diff --git a/frontend/pages/file-storage/LocationBar.test.jsx b/frontend/pages/file-storage/LocationBar.test.jsx
new file mode 100644
index 000000000..39dab4d47
--- /dev/null
+++ b/frontend/pages/file-storage/LocationBar.test.jsx
@@ -0,0 +1,132 @@
+import React from 'react';
+import '@testing-library/jest-dom';
+import { render, screen, fireEvent } from '@testing-library/react';
+import { MemoryRouter } from 'react-router-dom';
+import LocationBar from './LocationBar';
+
+describe('LocationBar Component', () => {
+ const mockOnNavigate = jest.fn();
+ const storageRoot = 'https://example.gov/~assets';
+ const siteId = '123';
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('renders root directory correctly, in text (not link) if current directory', () => {
+ render(
+ /* MemoryRouter allows us to render components that use react Link */
+
+
+ ,
+ );
+
+ expect(screen.getByText(`${storageRoot}/`)).toBeInTheDocument();
+ expect(
+ screen.queryByRole('link', { name: `${storageRoot}/` }),
+ ).not.toBeInTheDocument();
+ expect(screen.queryByText('../')).not.toBeInTheDocument();
+ });
+
+ it('renders single-level folder correctly, with parent as link', () => {
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByRole('link', { name: `${storageRoot}/` })).toBeInTheDocument();
+ expect(screen.getByText('foo/')).toBeInTheDocument();
+ });
+
+ test('renders nested folders with ../ link', () => {
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByRole('link', { name: '../' })).toBeInTheDocument();
+ expect(screen.getByText('bar/')).toBeInTheDocument();
+ });
+
+ it('clicking a breadcrumb calls onNavigate', () => {
+ render(
+
+
+ ,
+ );
+
+ fireEvent.click(screen.getByRole('link', { name: '../' }));
+ expect(mockOnNavigate).toHaveBeenCalledWith('foo/');
+
+ fireEvent.click(screen.getByRole('link', { name: 'foo/' }));
+ expect(mockOnNavigate).toHaveBeenCalledWith('foo/');
+ });
+
+ it('renders deep nested path correctly', () => {
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByRole('link', { name: '../' })).toBeInTheDocument();
+ expect(screen.getByText('baz/')).toBeInTheDocument();
+ });
+
+ it('does not render ../ at root level', () => {
+ render(
+
+
+ ,
+ );
+
+ expect(screen.queryByRole('link', { name: '../' })).not.toBeInTheDocument();
+ });
+
+ test('clicking domain/~assets resets to root if it is a link', () => {
+ render(
+
+
+ ,
+ );
+ fireEvent.click(screen.getByRole('link', { name: `${storageRoot}/` }));
+ expect(mockOnNavigate).toHaveBeenCalledWith('/');
+ });
+});
diff --git a/frontend/pages/file-storage/Log.js b/frontend/pages/file-storage/Log.js
new file mode 100644
index 000000000..aba321ddc
--- /dev/null
+++ b/frontend/pages/file-storage/Log.js
@@ -0,0 +1,20 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import LoadingIndicator from '@shared/LoadingIndicator';
+const isLoading = false;
+
+function FileStorageLogs() {
+ if (isLoading) {
+ return ;
+ }
+
+ return (
+
+
+
+ );
+}
+
+export { FileStorageLogs };
+export default FileStorageLogs;
diff --git a/frontend/pages/file-storage/index.js b/frontend/pages/file-storage/index.js
new file mode 100644
index 000000000..bb454c936
--- /dev/null
+++ b/frontend/pages/file-storage/index.js
@@ -0,0 +1,214 @@
+import React from 'react';
+import { useParams, useSearchParams } from 'react-router-dom';
+import { useSelector } from 'react-redux';
+import AlertBanner from '@shared/alertBanner';
+import LocationBar from '../file-storage/LocationBar';
+import FileDetails from '../file-storage/FileDetails';
+import FileList from '../file-storage/FileList';
+import LoadingIndicator from '@shared/LoadingIndicator';
+import Pagination from '@shared/Pagination';
+
+import { currentSite } from '@selectors/site';
+
+
+
+function FileStoragePage() {
+ const [searchParams, setSearchParams] = useSearchParams();
+ const { id } = useParams();
+ let path = decodeURIComponent(searchParams.get('path') || "/").replace(/^\/+/, "/");
+
+ // Ensure path always ends in "/" because we use it for asset url links
+ if (path !== "/") {
+ path = path.replace(/\/+$/, "") + "/";
+ }
+
+ const detailsFile = searchParams.get('details');
+ const site = useSelector((state) => currentSite(state.sites, id));
+ const storageRoot = `${site.siteOrigin}/~assets`
+
+
+ // Mocked API state for now
+ const publicFilesActions = { isLoading: false, error: null };
+ const mockFiles = [
+ {
+ name: "Documents",
+ type: "directory",
+ updatedAt: "2025-02-12T22:30:00Z",
+ updatedBy: "user@federal.gov",
+ size: null,
+ },
+ {
+ name: "report.pdf",
+ type: "application/pdf",
+ updatedAt: "2025-01-09T15:45:00Z",
+ updatedBy: "user@federal.gov",
+ size: 23456,
+ },
+ {
+ name: "super-dooper-way-too-long-file-name-must-truncate-presentation.pptx",
+ type: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
+ updatedAt: "2024-02-08T09:15:00Z",
+ updatedBy: "user@federal.gov",
+ size: 63827592,
+ }
+ ];
+ const fileDetails = mockFiles.find(file => file.name === detailsFile);
+
+
+ const mockPagination = {
+ currentPage: parseInt(searchParams.get('page')) || 1,
+ totalPages: 20,
+ totalItems: 392,
+ };
+
+ const DEFAULT_SORT_KEY = "name";
+ const DEFAULT_SORT_ORDER = "asc";
+ const REVERSE_SORT_ORDER = "desc";
+
+ const sortKey = searchParams.get('sortKey') || DEFAULT_SORT_KEY;
+ const sortOrder = searchParams.get('sortOrder') || DEFAULT_SORT_ORDER;
+
+ const fetchedPublicFiles = {
+ ...mockPagination,
+ sortKey,
+ sortOrder,
+ data: mockFiles
+ };
+
+ const handleNavigate = (newPath) => {
+ const decodedPath = decodeURIComponent(newPath);
+ // Remove trailing slash if it exists
+ let normalizedPath = `${decodedPath.replace(/\/+$/, "")}`;
+
+
+ if (normalizedPath !== "/") {
+ normalizedPath += "/"; // Ensure folders always end with "/"
+ }
+ searchParams.delete('details');
+ searchParams.delete('page');
+
+ setSearchParams((prev) => {
+ const newParams = new URLSearchParams(prev);
+ newParams.set("path", normalizedPath);
+ return newParams;
+ });
+ window.scrollTo(0, 0,);
+
+ };
+
+
+ const handlePageChange = (newPage) => {
+ if (newPage === mockPagination.currentPage) return;
+ setSearchParams(prev => {
+ const newParams = new URLSearchParams(prev);
+ newParams.set('page', newPage);
+ return newParams;
+ });
+ };
+
+ const handleViewDetails = (file) => {
+ setSearchParams({
+ ...Object.fromEntries(searchParams),
+ details: file
+ });
+ };
+
+ const handleCloseDetails = () => {
+ setSearchParams(prev => {
+ const newParams = new URLSearchParams(prev);
+ newParams.delete('details');
+ return newParams;
+ });
+ };
+
+
+ const handleDelete = (item) => {
+ const isFolder = item.type === 'directory';
+ const confirmMessage = isFolder
+ ? `Are you sure you want to delete the folder "${item.name}"? Please check that it does not contain any files.`
+ : `Are you sure you want to delete the file "${item.name}"?`;
+
+ if (!window.confirm(confirmMessage)) return;
+
+ console.log("Deleting:", item); // Replace with actual API call
+ };
+
+ const handleSort = (sortKey) => {
+ const currentSortKey = searchParams.get('sortKey') || DEFAULT_SORT_KEY;
+ const currentSortOrder = searchParams.get('sortOrder') || DEFAULT_SORT_ORDER;
+
+ const isSameKey = currentSortKey === sortKey;
+ const newSortOrder = isSameKey && currentSortOrder === DEFAULT_SORT_ORDER ? REVERSE_SORT_ORDER : DEFAULT_SORT_ORDER;
+
+ setSearchParams({
+ ...Object.fromEntries(searchParams),
+ sortKey,
+ sortOrder: newSortOrder
+ });
+ };
+
+
+ if (publicFilesActions.isLoading) {
+ return ;
+ }
+
+ if (publicFilesActions.error) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+ {detailsFile && (
+
+ )}
+ {!detailsFile && (
+ <>
+
+
+
+ >
+ )}
+
+ );
+}
+
+export { FileStoragePage };
+export default FileStoragePage;
diff --git a/frontend/pages/sites/$siteId/index.jsx b/frontend/pages/sites/$siteId/index.jsx
index 33d20e456..a742e5b95 100644
--- a/frontend/pages/sites/$siteId/index.jsx
+++ b/frontend/pages/sites/$siteId/index.jsx
@@ -32,10 +32,15 @@ export const SITE_NAVIGATION_CONFIG = [
icon: 'IconGear',
},
{
- display: 'Uploaded files',
+ display: 'Published builds',
route: 'published',
icon: 'IconCloudUpload',
},
+ {
+ display: 'Public storage',
+ route: 'storage',
+ icon: 'IconAttachment',
+ },
];
export const SITE_TITLE_CONFIG = [
diff --git a/frontend/routes.js b/frontend/routes.js
index 0aae280ab..c5d4fe333 100644
--- a/frontend/routes.js
+++ b/frontend/routes.js
@@ -17,6 +17,8 @@ import DomainList from '@pages/sites/$siteId/custom-domains';
import NewCustomDomain from '@pages/sites/$siteId/custom-domains/new';
import EditCustomDomain from '@pages/sites/$siteId/custom-domains/$domainId/edit';
import Reports from '@pages/sites/$siteId/reports';
+import FileStoragePage from '@pages/file-storage';
+import FileStorageLogs from '@pages/file-storage/Log';
import Settings from '@pages/settings';
import NotFound from '@pages/NotFound';
import ErrorMessage from '@pages/ErrorMessage';
@@ -56,6 +58,8 @@ export default (
} />
} />
} />
+ } />
+ } />
} />
redirect('../reports')} />
{process.env.FEATURE_BUILD_TASKS === 'active' && (
diff --git a/frontend/sass/_icons.scss b/frontend/sass/_icons.scss
index a5aa024d3..59d08daaf 100644
--- a/frontend/sass/_icons.scss
+++ b/frontend/sass/_icons.scss
@@ -1,10 +1,4 @@
-$color-blue: #1c5982;
-$color-blue-dark: #112e51;
-$color-blue-bright: rgb(5, 104, 253);
-
-
-$link-color: $color-blue;
-$link-color-active: $color-blue-dark;
+@use 'variables' as *;
.repo-link {
svg {
diff --git a/frontend/sass/_tables.scss b/frontend/sass/_tables.scss
index c9e82ba90..f2c16b473 100644
--- a/frontend/sass/_tables.scss
+++ b/frontend/sass/_tables.scss
@@ -1,3 +1,4 @@
+@use 'variables' as *;
#js-app .usa-table--borderless {
thead,
@@ -472,3 +473,235 @@
font-size: 1em;
line-height: 1.4;
}
+
+/* --- Sortable table --- */
+
+$selectedColumnBackground: #62a5d522;
+$selectedColumnText: #071018;
+$sortableBorderColor: #5c759980;
+
+// Shared table styles
+@mixin table-header-unsorted-styles {
+ position: relative;
+ &::after {
+ border-bottom-color: transparent;
+ border-bottom-style: solid;
+ border-bottom-width: 1px;
+ bottom: 0;
+ content: "";
+ height: 0;
+ left: 0;
+ position: absolute;
+ width: 100%;
+ }
+}
+
+@mixin table-button-default-styles {
+ width: 2rem;
+ height: 2rem;
+ color: $color-gray-dark;
+ cursor: pointer;
+ display: inline-block;
+ margin: 0;
+ position: absolute;
+ right: 0.25rem;
+ text-align: center;
+ text-decoration: none;
+ top: 50%;
+ transform: translate(0, -50%);
+ .usa-icon {
+ width: 1.75rem;
+ height: 1.75rem;
+ vertical-align: middle;
+ & > g {
+ fill: transparent;
+ }
+ }
+}
+
+// The SVG in the sortable column button contains three icon shapes.
+// This CSS controls which of the shapes is 'filled' when active.
+
+@mixin table-button-unsorted-styles {
+ @include table-button-default-styles;
+ .usa-icon > g.unsorted {
+ fill: $color-gray-dark;
+ }
+ &:hover .usa-icon > g.unsorted {
+ fill: $color-blue;
+ }
+}
+
+@mixin table-button-sorted-ascending-styles {
+ @include table-button-default-styles;
+ .usa-icon > .ascending {
+ fill: $color-blue-dark;
+ }
+}
+
+@mixin table-button-sorted-descending-styles {
+ @include table-button-default-styles;
+ .usa-icon > .descending {
+ fill: $color-blue-dark;
+ }
+}
+
+
+// specificity because our old tables have a lot of overrides
+#js-app .usa-table--sortable {
+
+ /* stylelint-disable selector-class-pattern */
+ th[data-sortable],
+ th[data-is-sortable] {
+ @include table-header-unsorted-styles;
+ &:not([aria-sort]),
+ &[aria-sort="none"] {
+ .usa-table__sort-button {
+ @include table-button-unsorted-styles;
+ }
+ }
+
+ &[aria-sort="descending"],
+ &[aria-sort="ascending"] {
+ background-color: $selectedColumnBackground;
+ }
+
+ &[aria-sort="descending"] {
+ .usa-table__sort-button {
+ @include table-button-sorted-descending-styles;
+ }
+ }
+
+ &[aria-sort="ascending"] {
+ .usa-table__sort-button {
+ @include table-button-sorted-ascending-styles;
+ }
+ }
+ }
+ /* stylelint-enable selector-class-pattern */
+
+ thead {
+ th[aria-sort] {
+ background-color: $selectedColumnBackground;
+ color: $selectedColumnText;
+ }
+ th {
+ border-color: $sortableBorderColor;
+ }
+ }
+
+ td[data-sort-active],
+ th[data-sort-active] {
+ background-color: $selectedColumnBackground;
+ color: $selectedColumnText;
+ }
+
+ td {
+ white-space: nowrap;
+ border-color: $sortableBorderColor;
+ }
+
+ td.file-name-cell{
+ max-width: 0;
+ }
+ .width-last-mod {
+ width: 18ch;
+ }
+ .width-actions {
+ width: 24ch;
+ }
+ .file-icon {
+ width: 1em;
+ height: 1.2em;
+ margin-right: .5rem;
+ svg {
+ display: inline-block;
+ fill: currentColor;
+ height: 1em;
+ position: relative;
+ width: 1em;
+ flex-shrink: 0;
+ }
+ }
+ .file-name-wrap {
+ display: flex;
+ height: 2rem;
+ flex-direction: row;
+ flex-wrap: nowrap;
+ align-items: center;
+ }
+ .file-name {
+ text-overflow: ellipsis;
+ width: 100%;
+ white-space: nowrap;
+ display: block;
+ overflow: hidden;
+ -webkit-font-smoothing: auto;
+ color: $color-blue-dark;
+ height: 1.2em;
+ line-height: 1.2em;
+ }
+ .delete-button {
+ padding: 0.4em 0.6em;
+ font-size: 1.1em;
+ border-radius: 4px;
+ white-space: nowrap;
+ margin-right: 0;
+ // mimics error colors from USWDS -- no outline button for this
+ box-shadow: inset 0 0 0 2px #d83933;
+ color: #d83933;
+ &:hover {
+ box-shadow: inset 0 0 0 2px #b50909;
+ color: #b50909
+ }
+ &:active {
+ box-shadow: inset 0 0 0 2px #8b0a03;
+ color: #8b0a03
+ }
+ }
+}
+
+/* File details */
+
+.file-details {}
+.file-details__header {
+ display: flex;
+ flex-flow: row nowrap;
+ padding: 1rem;
+ align-items: center;
+ .usa-icon {
+ height: 1.5rem;
+ width: 1.5rem;
+ }
+ h3 {
+ margin: 0;
+ }
+}
+.file-details__close {
+ margin-left: auto;
+ color: $color-blue-dark;
+}
+.file-details__table {
+ margin: 0;
+ width: 100%;
+ th, td {
+ border-color: $sortableBorderColor;
+ }
+ .text-bold,
+ th {
+ font-weight: 600;
+ }
+ th {
+ color: #5d5d5d;
+ min-height: 3rem;
+ min-width: 20ch;
+ }
+}
+
+.border-base {
+ border-color: $sortableBorderColor;
+}
+
+a {
+ cursor: pointer;
+}
\ No newline at end of file
diff --git a/frontend/sass/_variables.scss b/frontend/sass/_variables.scss
index 0a4d56bcb..46a87443e 100644
--- a/frontend/sass/_variables.scss
+++ b/frontend/sass/_variables.scss
@@ -6,7 +6,7 @@ $white-shaded: #eef4fb;
$color-blue: #1c5982;
$color-blue-dark: #112e51;
-$color-blue-bright: rgb(5, 104, 253);
+$color-blue-bright: #0568fd;
$color-white: $white;
$color-gray: #526577;
diff --git a/frontend/shared/Pagination.jsx b/frontend/shared/Pagination.jsx
new file mode 100644
index 000000000..fce9bb3bc
--- /dev/null
+++ b/frontend/shared/Pagination.jsx
@@ -0,0 +1,159 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+const MAX_VISIBLE_PAGES = 5;
+// Minimum distance from start/end before showing ellipsis
+const ELLIPSIS_THRESHOLD = Math.floor(MAX_VISIBLE_PAGES / 2) + 1;
+
+const Pagination = ({
+ currentPage,
+ totalPages,
+ totalItems,
+ itemsOnCurrentPage,
+ onPageChange,
+}) => {
+ const getPageNumbers = () => {
+ const pageNumbers = [];
+
+ if (totalPages <= MAX_VISIBLE_PAGES) {
+ return Array.from({ length: totalPages }, (_, i) => ({
+ type: i + 1 === currentPage ? 'current' : 'page',
+ page: i + 1,
+ }));
+ }
+
+ pageNumbers.push({ type: currentPage === 1 ? 'current' : 'page', page: 1 });
+
+ if (currentPage > ELLIPSIS_THRESHOLD) {
+ pageNumbers.push({ type: 'ellipsis' });
+ }
+
+ let startPage = Math.max(2, currentPage - 1);
+ let endPage = Math.min(totalPages - 1, currentPage + 1);
+
+ for (let i = startPage; i <= endPage; i++) {
+ pageNumbers.push({ type: i === currentPage ? 'current' : 'page', page: i });
+ }
+
+ if (currentPage < totalPages - ELLIPSIS_THRESHOLD) {
+ pageNumbers.push({ type: 'ellipsis' });
+ }
+
+ pageNumbers.push({
+ type: currentPage === totalPages ? 'current' : 'page',
+ page: totalPages,
+ });
+
+ return pageNumbers;
+ };
+
+ return (
+
+
+ Showing {itemsOnCurrentPage} of {totalItems} total files in this folder
+
+ {totalPages > 1 && (
+
+ )}
+
+ );
+};
+
+const PaginationButton = ({
+ page,
+ label,
+ children,
+ type = 'button',
+ isCurrent = false,
+ isDisabled = false,
+ customClass = undefined,
+ onClick,
+}) => (
+
+
+
+);
+
+PaginationButton.propTypes = {
+ page: PropTypes.number.isRequired,
+ label: PropTypes.string.isRequired,
+ children: PropTypes.node,
+ type: PropTypes.string,
+ isCurrent: PropTypes.bool,
+ isDisabled: PropTypes.bool,
+ customClass: PropTypes.string,
+ onClick: PropTypes.func.isRequired,
+};
+
+Pagination.propTypes = {
+ currentPage: PropTypes.number.isRequired,
+ totalPages: PropTypes.number.isRequired,
+ totalItems: PropTypes.number.isRequired,
+ itemsOnCurrentPage: PropTypes.number.isRequired,
+ onPageChange: PropTypes.func.isRequired,
+};
+
+export default Pagination;
diff --git a/frontend/shared/Pagination.test.jsx b/frontend/shared/Pagination.test.jsx
new file mode 100644
index 000000000..ae171b284
--- /dev/null
+++ b/frontend/shared/Pagination.test.jsx
@@ -0,0 +1,115 @@
+import React from 'react';
+import { render, screen, fireEvent } from '@testing-library/react';
+import '@testing-library/jest-dom';
+import Pagination from './Pagination';
+
+const mockOnPageChange = jest.fn();
+
+const defaultProps = {
+ currentPage: 1,
+ totalPages: 12,
+ totalItems: 111,
+ itemsOnCurrentPage: 10,
+ onPageChange: mockOnPageChange,
+};
+
+const renderPagination = (props = {}) => {
+ return render();
+};
+
+describe('Pagination Component', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ test('renders pagination label correctly', () => {
+ renderPagination();
+ expect(
+ screen.getByText(/Showing 10 of 111 total files in this folder/i),
+ ).toBeInTheDocument();
+ });
+
+ test('displays Previous button disabled on first page', () => {
+ renderPagination();
+ expect(screen.getByLabelText(/Previous page/i)).toBeDisabled();
+ });
+
+ test('displays Next button disabled on last page', () => {
+ renderPagination({ currentPage: defaultProps.totalPages });
+ expect(screen.getByLabelText(/Next page/i)).toBeDisabled();
+ });
+
+ test('calls onPageChange when clicking Next button', () => {
+ renderPagination();
+ fireEvent.click(screen.getByLabelText(/Next page/i));
+ expect(mockOnPageChange).toHaveBeenCalledWith(2);
+ });
+
+ test('calls onPageChange when clicking Previous button', () => {
+ renderPagination({ currentPage: 2 });
+ fireEvent.click(screen.getByLabelText(/Previous page/i));
+ expect(mockOnPageChange).toHaveBeenCalledWith(1);
+ });
+
+ test('calls onPageChange when clicking a numbered page', () => {
+ renderPagination({ currentPage: 5 });
+ fireEvent.click(screen.getByText('4'));
+ expect(mockOnPageChange).toHaveBeenCalledWith(4);
+ });
+
+ test('highlights the current page', () => {
+ renderPagination({ currentPage: 3 });
+ const currentPageElement = screen.getByRole('button', { current: 'page' });
+ expect(currentPageElement).toHaveTextContent('3');
+ });
+
+ test('clicking current page does not trigger onPageChange', () => {
+ renderPagination({ currentPage: 4 });
+ fireEvent.click(screen.getByText('4'));
+ expect(mockOnPageChange).not.toHaveBeenCalled();
+ });
+
+ test('displays full range of pages if total pages is 5 or fewer', () => {
+ renderPagination({ totalPages: 4, totalItems: 50 });
+ // eslint-disable-next-line no-plusplus
+ for (let i = 1; i <= 4; i++) {
+ expect(screen.getByText(i.toString())).toBeInTheDocument();
+ }
+ expect(screen.queryByText('…')).not.toBeInTheDocument();
+ });
+
+ test('hides pagination controls if only one page exists', () => {
+ renderPagination({ totalPages: 1, totalItems: 10 });
+ expect(
+ screen.queryByRole('navigation', { name: 'Pagination' }),
+ ).not.toBeInTheDocument();
+ });
+
+ test('displays ellipsis for later pages when early in the pages', () => {
+ renderPagination({ currentPage: 2 });
+ expect(screen.getByText('…')).toBeInTheDocument();
+ expect(screen.getByText('12')).toBeInTheDocument();
+ });
+
+ test('displays ellipsis for earlier pages when near the end of pages', () => {
+ renderPagination({ currentPage: 9 });
+ expect(screen.getByText('…')).toBeInTheDocument();
+ expect(screen.getByText('1')).toBeInTheDocument();
+ });
+
+ test('displays both ellipsis when near the center of pages', () => {
+ renderPagination({ currentPage: 5 });
+ expect(screen.getByText('1')).toBeInTheDocument();
+ expect(screen.getByText('12')).toBeInTheDocument();
+ expect(screen.getAllByText('…')).toHaveLength(2); // Two ellipses
+ });
+
+ test('ellipsis elements are not interactive', () => {
+ renderPagination({ currentPage: 6 });
+ const ellipses = screen.getAllByText('…');
+ ellipses.forEach((ellipsis) => {
+ expect(ellipsis).not.toHaveAttribute('role');
+ expect(ellipsis).not.toHaveAttribute('tabindex');
+ });
+ });
+});
diff --git a/frontend/shared/icons.js b/frontend/shared/icons.js
index 040cbe049..91cc865ed 100644
--- a/frontend/shared/icons.js
+++ b/frontend/shared/icons.js
@@ -1,3 +1,4 @@
+import IconAttachment from '../../public/images/icons/icon-attachment.svg';
import IconBook from '../../public/images/icons/icon-book.svg';
import IconBranch from '../../public/images/icons/icon-branch.svg';
import IconCheckCircle from '../../public/images/icons/icon-check-circle-solid.svg';
@@ -8,8 +9,10 @@ import IconEnvelope from '../../public/images/icons/icon-envelope.svg';
import IconExclamationCircle from '../../public/images/icons/icon-exclamation-circle-solid.svg';
import IconExperiment from '../../public/images/icons/icon-experiment.svg';
import IconExternalLink from '../../public/images/icons/icon-external-link.svg';
+import IconFolder from '../../public/images/icons/icon-folder.svg';
import IconGear from '../../public/images/icons/icon-gear.svg';
import IconGitHub from '../../public/images/icons/icon-github.svg';
+import IconGlobe from '../../public/images/icons/icon-globe.svg';
import IconIgnore from '../../public/images/icons/icon-ignore.svg';
import IconLink from '../../public/images/icons/icon-link.svg';
import IconPeople from '../../public/images/icons/icon-people.svg';
@@ -22,6 +25,7 @@ import IconX from '../../public/images/icons/icon-x.svg';
import IconTrash from '../../public/images/icons/icon-trash.svg';
export {
+ IconAttachment,
IconBook,
IconBranch,
IconCheckCircle,
@@ -31,8 +35,10 @@ export {
IconExclamationCircle,
IconExperiment,
IconExternalLink,
+ IconFolder,
IconGear,
IconGitHub,
+ IconGlobe,
IconIgnore,
IconLink,
IconPeople,
diff --git a/package.json b/package.json
index 0c6c3a341..3c220d584 100644
--- a/package.json
+++ b/package.json
@@ -45,6 +45,7 @@
"passport": "^0.7.0",
"passport-github": "^1.1.0",
"passport-oauth2": "^1.8.0",
+ "pretty-bytes": "^6.1.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-highlight": "^0.15.0",
diff --git a/public/images/icons/icon-attachment.svg b/public/images/icons/icon-attachment.svg
new file mode 100644
index 000000000..804eb69d0
--- /dev/null
+++ b/public/images/icons/icon-attachment.svg
@@ -0,0 +1,4 @@
+
+
diff --git a/public/images/icons/icon-folder.svg b/public/images/icons/icon-folder.svg
new file mode 100644
index 000000000..1be587606
--- /dev/null
+++ b/public/images/icons/icon-folder.svg
@@ -0,0 +1,3 @@
+
diff --git a/public/images/icons/icon-globe.svg b/public/images/icons/icon-globe.svg
new file mode 100644
index 000000000..cb73d24ca
--- /dev/null
+++ b/public/images/icons/icon-globe.svg
@@ -0,0 +1,3 @@
+
diff --git a/public/images/icons/icon-link.svg b/public/images/icons/icon-link.svg
index 052f2e33f..0bedcfecb 100644
--- a/public/images/icons/icon-link.svg
+++ b/public/images/icons/icon-link.svg
@@ -1,5 +1,5 @@
diff --git a/yarn.lock b/yarn.lock
index a30792608..8a8f28af3 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -9254,6 +9254,11 @@ prettier@^3.3.3:
resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.3.3.tgz#30c54fe0be0d8d12e6ae61dbb10109ea00d53105"
integrity sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==
+pretty-bytes@^6.1.1:
+ version "6.1.1"
+ resolved "https://registry.yarnpkg.com/pretty-bytes/-/pretty-bytes-6.1.1.tgz#38cd6bb46f47afbf667c202cfc754bffd2016a3b"
+ integrity sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==
+
pretty-format@^27.0.2:
version "27.5.1"
resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-27.5.1.tgz#2181879fdea51a7a5851fb39d920faa63f01d88e"