From ebb9f11b4af6659b80c54ede60760159c3389dc7 Mon Sep 17 00:00:00 2001 From: Colin Date: Fri, 13 Dec 2024 14:55:28 +0000 Subject: [PATCH] feat: Game Data Index Browser WIP --- src/renderer/components/Header.tsx | 33 +- src/renderer/components/app.tsx | 1 + .../components/pages/GameDataPage.tsx | 332 ++++++++++++++++++ src/renderer/router.tsx | 9 + src/shared/Paths.ts | 3 +- static/window/styles/core.css | 61 ++++ static/window/styles/fancy.css | 6 + 7 files changed, 430 insertions(+), 15 deletions(-) create mode 100644 src/renderer/components/pages/GameDataPage.tsx diff --git a/src/renderer/components/Header.tsx b/src/renderer/components/Header.tsx index 0828ddde9..a26d7adb7 100644 --- a/src/renderer/components/Header.tsx +++ b/src/renderer/components/Header.tsx @@ -296,21 +296,21 @@ export class Header extends React.Component { ]; const menu = remote.Menu.buildFromTemplate(contextButtons); menu.popup({ window: remote.getCurrentWindow() }); - }}/> + }} /> )) : browseViews.map(view => ( + link={joinLibraryRoute(view)} /> )) } - { this.props.preferencesData.useCustomViews && ( + {this.props.preferencesData.useCustomViews && (
  • - +
  • )} - { enableEditing ? ( + {enableEditing ? ( <> { title={strings.categories} link={Paths.CATEGORIES} /> - ) : undefined } + ) : undefined} - { (onlineManual || offlineManual) && ( + {(onlineManual || offlineManual) && ( @@ -334,16 +334,21 @@ export class Header extends React.Component { - { enableEditing ? ( - - ) : undefined } - { showDeveloperTab ? ( + {enableEditing ? ( + <> + + + + ) : undefined} + {showDeveloperTab ? ( - ) : undefined } + ) : undefined} {/* Right-most portion */} diff --git a/src/renderer/components/app.tsx b/src/renderer/components/app.tsx index 36ea21a93..8ed410f2e 100644 --- a/src/renderer/components/app.tsx +++ b/src/renderer/components/app.tsx @@ -1414,6 +1414,7 @@ export class App extends React.Component { componentStatuses: this.props.main.componentStatuses, openFlashpointManager: this.openFlashpointManager, metaState: this.props.currentView.data.metaState, + performFpfssAction: (cb) => this.performFpfssAction(cb), searchStatus: null, // TODO: remove }; diff --git a/src/renderer/components/pages/GameDataPage.tsx b/src/renderer/components/pages/GameDataPage.tsx new file mode 100644 index 000000000..85e4e492a --- /dev/null +++ b/src/renderer/components/pages/GameDataPage.tsx @@ -0,0 +1,332 @@ +import { ReactNode, useCallback, useMemo, useState } from "react"; +import { OpenIcon } from "../OpenIcon"; +import { FpfssUser } from "@shared/back/types"; +import { ArrowKeyStepper, AutoSizer, List, ListRowProps } from "react-virtualized-reactv17"; +import { sizeToString } from "@shared/Util"; + +type SearchType = 'hash' | 'path'; + +type IndexMatch = { + crc32: string; + md5: string; + sha1: string; + sha256: string; + path: string; + size: number; + date_added: string; + game_id: string; +} + +type IndexGame = { + id: string; + date_added: string; + platform_name: string; + title: string; +} + +type IndexHashResponse = { + data: IndexMatch[]; + games: IndexGame[]; + hash: string; + type: 'crc32' | 'md5' | 'sha1' | 'sha256'; +} + +type IndexPathResponse = { + data: IndexMatch[]; + games: IndexGame[]; + paths: string[]; +} + +type IndexContentTree = { + root: IndexContentTreeDirectory +} + +type IndexContentTreeDirectory = { + kind: 'dir'; + expanded: boolean; + path: string; + directories: { + [key: string]: IndexContentTreeDirectory; + }; + files: IndexContentTreeFile[]; +} + +type IndexContentTreeFile = { + kind: 'file'; + match: IndexMatch; +} + +type IndexTableRow = { + depth: number; + node: IndexContentTreeDirectory | IndexContentTreeFile; +} + +export type GameDataPageProps = { + performFpfssAction: (cb: (user: FpfssUser) => any) => void; +} + +export function GameDataPage(props: GameDataPageProps) { + const [searchValue, setSearchValue] = useState(''); + const [order, setOrder] = useState('path'); + const [contentTree, setContentTree] = useState(null); + const [renderKey, setRenderKey] = useState(0); + + const tableRows = useMemo(() => { + console.log('rerendered table row'); + if (contentTree === null) { + return null; + } else { + return getIndexTableRows(contentTree.root); + } + }, [contentTree, renderKey]); + console.log('rows'); + console.log(tableRows); + + const performSearch = () => { + props.performFpfssAction(async (user) => { + if (order === 'hash') { + const url = `https://fpfss.unstable.life/api/index/hash/${searchValue}`; + const res = await fetch(url, { + headers: { + 'Authorization': `Bearer ${user.accessToken}`, + 'User-Agent': 'Flashpoint Launcher', + 'Accept': 'application/json', + }, + }) + if (res.ok) { + const data: IndexHashResponse = await res.json(); + console.log('set tree'); + setContentTree(createContentTree(data.data, data.games)); + } else { + alert(`Error: ${res.statusText}s`); + } + } else { + const url = 'https://fpfss.unstable.life/api/index/path'; + const body = { + path: searchValue + }; + const res = await fetch(url, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${user.accessToken}`, + 'User-Agent': 'Flashpoint Launcher', + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + body: JSON.stringify(body), + }); + if (res.ok) { + const data: IndexPathResponse = await res.json(); + console.log('set tree'); + setContentTree(createContentTree(data.data, data.games)); + } else { + alert(`Error: ${res.statusText}s`); + } + } + }) + }; + + const onClickNode = useCallback((node: IndexContentTreeDirectory | IndexContentTreeFile) => { + console.log('clicked'); + if (node.kind === 'dir') { + node.expanded = !node.expanded; + setContentTree(contentTree); + setRenderKey(renderKey + 1); + } + }, [contentTree, renderKey]); + + return ( +
    + {/* Main Content Area */} +
    + {/* Header */} +
    +
    + +
    + { + if (event.key === 'Enter') { + performSearch(); + } + }} + onChange={(event) => setSearchValue(event.target.value)} + /> + +
    + + {/* Results Label */} +
    +
    + {`{num} Results: {Search Value}`} +
    + + {/* Scrollable Content Tree */} +
    + {tableRows ? ( + + {({ width, height }) => { + return ( + + {({ onSectionRendered }) => ( + rowRenderer(cellProps, tableRows, onClickNode)} + rowCount={tableRows.length} /> + )} + + ); + }} + + + ) : ''} +
    +
    +
    + + {/* Utilities Sidebar */} +
    +
    + sup +
    +
    +
    + ); +} + +function rowRenderer( + cellProps: ListRowProps, + indexTree: IndexTableRow[] | null, + onClickNode: (node: IndexContentTreeDirectory | IndexContentTreeFile) => void, +): ReactNode { + if (indexTree === null) { + return
    + } + + const row = indexTree[cellProps.index]; + const node = row.node; + const depthDivs = []; + for (let i = 0; i < row.depth; i++) { + depthDivs.push( +
    + ) + } + return ( +
    + {depthDivs} +
    onClickNode(node)} + className='gdb-table-row-content'> + {node.kind === 'dir' ? ( + <> + +
    {node.path}
    + + ) : ( + <> + +
    {(() => { + const pathSlice = node.match.path.split('/'); + const name = pathSlice[pathSlice.length - 1]; + const sizeStr = sizeToString(node.match.size); + return `${name} (${sizeStr})`; + })()}
    + + )} +
    +
    + ) +} + +function getIndexTableRows(node: IndexContentTreeDirectory, depth: number = -1): IndexTableRow[] { + let rows: IndexTableRow[] = []; + + for (const dir of Object.values(node.directories)) { + rows.push({ + depth: depth + 1, + node: dir + }); + if (dir.expanded) { + rows = rows.concat(getIndexTableRows(dir, depth + 1)); + } + } + + if (node.expanded) { + for (const file of node.files) { + rows.push({ + depth: depth + 1, + node: file, + }); + } + } + + return rows; +} + +function createContentTree(data: IndexMatch[], games: IndexGame[]): IndexContentTree { + const rootNode: IndexContentTreeDirectory = { + kind: 'dir', + expanded: true, + path: '', + directories: {}, + files: [], + }; + + for (const match of data) { + // Find parent in root if possible + let curNode = rootNode; + const parts = match.path.split('/'); + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + if (i < parts.length - 1) { + // Directory + if (!curNode.directories[part]) { + curNode.directories[part] = { + kind: 'dir', + expanded: true, + path: part, + directories: {}, + files: [], + } + } + curNode = curNode.directories[part]; + } else { + // File + curNode.files.push({ + kind: 'file', + match, + }) + } + } + } + + return { + root: rootNode + } +} \ No newline at end of file diff --git a/src/renderer/router.tsx b/src/renderer/router.tsx index 3975bd5e5..c67e31c97 100644 --- a/src/renderer/router.tsx +++ b/src/renderer/router.tsx @@ -24,6 +24,7 @@ import { ConnectedTagsPage } from './containers/ConnectedTagsPage'; import { CreditsData } from './credits/types'; import { RequestState } from '@renderer/store/search/slice'; import { LoadingPage } from './components/pages/LoadingPage'; +import { GameDataPage, GameDataPageProps } from './components/pages/GameDataPage'; export type AppRouterProps = { fpfssUser: FpfssUser | null; @@ -72,6 +73,7 @@ export type AppRouterProps = { onMovePlaylistGame: (sourceGameId: string, destGameId: string) => void, searchStatus: string | null, metaState?: RequestState, + performFpfssAction: (cb: (user: FpfssUser) => any) => void, }; export class AppRouter extends React.Component { @@ -128,6 +130,9 @@ export class AppRouter extends React.Component { mad4fpEnabled: this.props.mad4fpEnabled, logoVersion: this.props.logoVersion, }; + const gameDataProps: GameDataPageProps = { + performFpfssAction: this.props.performFpfssAction, + } const developerProps: DeveloperPageProps = { devConsole: this.props.devConsole, devScripts: this.props.devScripts, @@ -177,6 +182,10 @@ export class AppRouter extends React.Component { path={Paths.CURATE} component={ConnectedCuratePage} { ...curateProps } /> + :not(:first-child) { margin-left: 0.3rem; } + +.gdb-container { + width: 100%; + height: 100%; + display: flex; + flex-direction: row; +} + +.gdb-content { + display: flex; + flex-direction: column; + flex-grow: 1; + height: 100%; +} + +.gdb-content-upper { + flex-shrink: 1; + padding: 1rem; + display: flex; + flex-direction: row; +} + +.gdb-content-upper > :not(:first-child) { + margin-left: 0.5rem; +} + +.gdb-content-main { + flex-grow: 1; + padding: 1rem; +} + +.gdb-sidebar { + display: flex; + flex-shrink: 0; + flex-direction: column; + height: 100%; + padding: 1rem; +} + +.gdb-table-container { + font-size: 14px; + margin-top: 1rem; + margin-bottom: 1rem; + height: 100%; + width: 100%; +} + +.gdb-table-row { + display: flex; + width: 100%; +} + +.gdb-table-row-content { + flex-grow: 1; + display: flex; +} + +.gdb-table-row-icon { + height: 100%; + width: auto; +} \ No newline at end of file diff --git a/static/window/styles/fancy.css b/static/window/styles/fancy.css index 42748953b..c7a81541f 100644 --- a/static/window/styles/fancy.css +++ b/static/window/styles/fancy.css @@ -1547,3 +1547,9 @@ body { .searchable-select-dropdown-item:hover { background-color: var(--layout__header-menu-item-hover-background); } + +.gdb-table-container { + background-color: var(--layout__secondary-background); + border: 2px solid var(--layout__primary-outline-color); + border-radius: 2px; +} \ No newline at end of file