frontend/app/browse/NewBrowseComponent.tsx (271 lines of code) (raw):

import React, {useState, useEffect} from "react"; import {RouteComponentProps} from "react-router"; import {makeStyles, Snackbar} from "@material-ui/core"; import BrowseSortOrder from "./BrowseSortOrder"; import {AdvancedSearchDoc, ArchiveEntry, CollectionNamesResponse, SortableField, SortOrder} from "../types"; import axios from "axios"; import {formatError} from "../common/ErrorViewComponent"; import MuiAlert from "@material-ui/lab/Alert"; import NewTreeView from "./NewTreeView"; import NewSearchComponent from "../common/NewSearchComponent"; import BrowsePathSummary from "./BrowsePathSummary"; import EntryDetails from "../Entry/EntryDetails"; import BoxSizing from "../common/BoxSizing"; import {urlParamsFromSearch} from "../common/UrlPathHelpers"; import Helmet from "react-helmet"; import BrowseFilter from "./BrowseFilter"; const useStyles = makeStyles({ browserWindow: { display: "grid", gridTemplateColumns: "repeat(20, 5%)", gridTemplateRows: "[top] 200px [info-area] auto [bottom]", height: "95vh" }, pathSelector: { gridColumnStart: 1, gridColumnEnd: 4, gridRowStart: "top", gridRowEnd: "bottom", borderRight: "1px solid white", padding: "1em", overflowX: "hidden", overflowY: "auto", }, sortOrderSelector: { gridColumnStart: 4, gridColumnEnd: 6, gridRowStart: "top", gridRowEnd: "info-area", padding: "1em", overflow: "hidden" }, summaryInfoArea: { gridColumnStart:6, gridColumnEnd: -4, gridRowStart: "top", gridRowEnd: "info-area", padding: "1em", overflow: "hidden" }, searchResultsArea: { gridColumnStart: 4, gridColumnEnd: -4, gridRowStart: "info-area", gridRowEnd: "bottom", overflowX: "hidden", overflowY: "auto", marginLeft: "auto", marginRight: "auto" }, detailsArea: { gridColumnStart: -4, gridColumnEnd: -1, gridRowStart: "top", gridRowEnd: "bottom", overflow: "hidden", //scrollbars are displayed by the child component }, filterArea: { gridColumnStart: 6, gridColumnEnd: 8, gridRowStart: "top", gridRowEnd: "info-area", padding: "1em", overflow: "hidden" }, }); const NewBrowseComponent:React.FC<RouteComponentProps> = (props) => { const [sortOrder, setSortOrder] = useState<SortOrder>("Descending"); const [sortField, setSortField] = useState<SortableField>("last_modified"); const [lastError, setLastError] = useState<string|undefined>(undefined); const [showingAlert, setShowingAlert] = useState(false); const [collectionNames, setCollectionNames] = useState<string[]>([]); const [currentCollection, setCurrentCollection] = useState(""); const [currentPath, setCurrentPath] = useState(""); const [reloadCounter, setReloadCounter] = useState(0); const [searchDoc, setSearchDoc] = useState<AdvancedSearchDoc>({collection:""}); const [pageSize, setPageSize] = useState(100); const [itemLimit, setItemLimit] = useState(200); const [newlyLightboxed, setNewlyLightboxed] = useState<string[]>([]); const [selectedEntry, setSelectedEntry] = useState<ArchiveEntry|undefined>(undefined); const [loading, setLoading] = useState(false); const [switchToPathsEnabled, setSwitchToPathsEnabled] = useState(false); const [urlRequestedItem, setUrlRequestedItem] = useState<string|undefined>(undefined); const [leftDividerPos, setLeftDividerPos] = useState(4); const [rightDividerPos, setRightDividerPos] = useState(-4); const classes = useStyles(); const [filterString, setFilterString] = useState<string>(""); const [typeString, setTypeString] = useState<string>("Any"); const refreshCollectionNames = async () => { try { const result = await axios.get<CollectionNamesResponse>("/api/browse/collections"); setCollectionNames(result.data.entries); if(currentCollection=="" && result.data.entries.length>0) setCurrentCollection(result.data.entries[0]); } catch (err) { console.error("Could not refresh collection names: ", err); setLastError(formatError(err, false)); setShowingAlert(true); } } /** * load in collection names at startup */ useEffect(()=>{ refreshCollectionNames(); }, []); const idXtractor = /^([^:]+):(.*)$/; /** * decode an incoming item id to extract the collection and path parts * @param itemId item id to decode * @returns undefined if the id is not valid, or a 2-element array consisting of (collection, path) */ const decodeIncomingItemId = (itemId:string) => { try { const decoded = atob(itemId); const matches = idXtractor.exec(decoded); if (matches) { return [matches[1], matches[2]] } else { return undefined } } catch(err) { console.error("Could not decode incoming string: ", err); return undefined; } } /** * if an item is specified on the url then open it. * we can only do this once the collections have been loaded in */ useEffect(()=>{ const urlParams = urlParamsFromSearch(props.location.search); if(urlParams.hasOwnProperty("open")) { console.log("Requested to open file id ", urlParams.open); const maybeDecoded = decodeIncomingItemId(urlParams.open); if(maybeDecoded) { console.log("Changing collection to ", maybeDecoded[0]); setSwitchToPathsEnabled(true); setCurrentCollection(maybeDecoded[0]); setUrlRequestedItem(urlParams.open); } else { console.error("The given item ID was not valid"); setLastError("The given item ID was not valid"); setShowingAlert(true); } } }, [collectionNames]); const stripTrailingSlash = (from:string)=> from.endsWith("/") ? from.slice(0,from.length-1) : from; /** * update search doc if collection name, path or reload counter changes */ useEffect(()=>{ const initialDoc:AdvancedSearchDoc = { collection: currentCollection, sortBy: sortField, sortOrder: sortOrder }; const docWithPath = currentPath=="" ? initialDoc : Object.assign(initialDoc, {path: stripTrailingSlash(currentPath)}); setSearchDoc(docWithPath); }, [currentCollection, reloadCounter, currentPath, sortField, sortOrder]); const pathOnlyRegex = new RegExp("/[^/]*$"); /** * make sure that the path is open if an item is selected. We have a global enable on this, otherwise whenever you * click an item everything not in that item's directory vanishes, which is quite confusing UX. */ useEffect(()=>{ if(selectedEntry && switchToPathsEnabled && currentPath!=selectedEntry.path) { const pathOnly = selectedEntry.path.replace(pathOnlyRegex, ""); console.log("setting path to ", pathOnly); setCurrentPath(pathOnly); setSwitchToPathsEnabled(false); } if(selectedEntry && currentCollection!=selectedEntry.bucket) { setCurrentCollection(selectedEntry.bucket) } }, [selectedEntry, switchToPathsEnabled]); useEffect(()=>{ if(selectedEntry) { setUrlRequestedItem(selectedEntry.id); } }, [selectedEntry]); useEffect(()=>{ if(urlRequestedItem) { props.history.push(`?open=${encodeURIComponent(urlRequestedItem)}`); } else { props.history.push("?"); } }, [urlRequestedItem]); const showComponentError = (errString:string)=>{ setLastError(errString); setShowingAlert(true); } const closeAlert = () => setShowingAlert(false); /** * callback for NewSearchcomponent, this is called whenever a data load is completed. * if we have an item to open provided on the url, then select it IF it appears in the search results * @param loadedData array of ArchiveEntry that were loaded */ const loadingDidComplete = (loadedData:ArchiveEntry[])=>{ if(urlRequestedItem) { const maybeItemToSelect = loadedData.filter(entry=>entry.id==urlRequestedItem); if(maybeItemToSelect.length>0) { setSelectedEntry(maybeItemToSelect[0]) } else { console.log("could not open item ", urlRequestedItem, " because it is not in the returned results") } } setLoading(false) } return <div className={classes.browserWindow}> <Helmet> <title>{ selectedEntry ? `${selectedEntry.path} - Archive Hunter` : "Archive Hunter" }</title> </Helmet> <Snackbar open={showingAlert} onClose={closeAlert} autoHideDuration={8000}> <MuiAlert severity="error" onClose={closeAlert}>{lastError}</MuiAlert> </Snackbar> <div className={classes.sortOrderSelector} style={{gridColumnStart: leftDividerPos, gridColumnEnd: leftDividerPos+2}}> <BrowseSortOrder sortOrder={sortOrder} field={sortField} orderChanged={(newOrder)=>setSortOrder(newOrder)} fieldChanged={(newField)=>setSortField(newField)}/> </div> <div className={classes.filterArea}> <BrowseFilter filterString={filterString} filterStringChanged={(newString)=>setFilterString(newString)} typeString={typeString} typeStringChanged={(newString)=>setTypeString(newString)}/> </div> <div className={classes.pathSelector} style={{gridColumnEnd: leftDividerPos}}> <BoxSizing justify="right" onRightClicked={()=>setLeftDividerPos((prev)=>prev+1)} onLeftClicked={()=>setLeftDividerPos((prev)=>prev-1)} /> <NewTreeView currentCollection={currentCollection} collectionList={collectionNames} collectionDidChange={(newCollection)=>setCurrentCollection(newCollection)} pathSelectionChanged={(newpath)=>setCurrentPath(newpath)} onError={showComponentError}/> </div> <div className={classes.summaryInfoArea} style={{gridColumnStart: leftDividerPos+2, gridColumnEnd: rightDividerPos}}> <BrowsePathSummary collectionName={currentCollection} searchDoc={searchDoc} path={currentPath} parentIsLoading={loading} refreshCb={()=>setReloadCounter(prevState => prevState+1)} goToRootCb={()=>setCurrentPath("")} showDotFiles={false} showDotFilesUpdated={()=>{}} onError={showComponentError} /> </div> <div className={classes.searchResultsArea} style={{gridColumnStart: leftDividerPos, gridColumnEnd: rightDividerPos}}> <NewSearchComponent pageSize={pageSize} itemLimit={itemLimit} newlyLightboxed={newlyLightboxed} onEntryClicked={(entry)=>setSelectedEntry(entry)} selectedEntry={selectedEntry} onErrorOccurred={showComponentError} advancedSearch={searchDoc} onLoadingStarted={()=>setLoading(true)} onLoadingFinished={loadingDidComplete} extraRequiredItemId={urlRequestedItem} filterString={filterString} typeString={typeString} /> </div> <div className={classes.detailsArea} style={{gridColumnStart: rightDividerPos}}> <BoxSizing justify="left" onRightClicked={()=>setRightDividerPos((prev)=>prev+1)} onLeftClicked={()=>setRightDividerPos((prev)=>prev-1)} /> {selectedEntry ? <EntryDetails entry={selectedEntry} autoPlay={true} showJobs={true} loadJobs={false} onError={(message:string)=>{ setLastError(message); setShowingAlert(true); }} openClicked={(itemId:string)=>props.history.push(`/item/${encodeURIComponent(itemId)}`)} //when the user adds to lightbox we record it here. This state var is bound to the NewSearchComponent //which will then re-load data for the given entry (after a short delay) lightboxedCb={(entryId:string)=>setNewlyLightboxed((prevState) => prevState.concat(entryId))} /> : undefined } </div> </div> } export default NewBrowseComponent;