diff --git a/src/app/App.js b/src/app/App.js index c96f932..f8a7979 100644 --- a/src/app/App.js +++ b/src/app/App.js @@ -30,6 +30,7 @@ const tryLoadable = (load, options) => options ); +const ConversionPage = tryLoadable(() => import("./ConversionPage")); const HomePage = tryLoadable(() => import("./HomePage")); const ItemSearchPage = tryLoadable(() => import("./ItemSearchPage")); const ItemPage = tryLoadable(() => import("./ItemPage")); @@ -162,6 +163,11 @@ function App() { + + + + + diff --git a/src/app/ConversionPage.js b/src/app/ConversionPage.js new file mode 100644 index 0000000..b16c461 --- /dev/null +++ b/src/app/ConversionPage.js @@ -0,0 +1,107 @@ +import React from "react"; +import { + Box, + CircularProgress, + CircularProgressLabel, + Flex, + Stack, +} from "@chakra-ui/react"; +import gql from "graphql-tag"; +import { useQuery } from "@apollo/client"; + +import { ErrorMessage, Heading1 } from "./util"; + +function ConversionPage() { + const { loading, error, data } = useQuery( + gql` + query ConversionPage_NoAuthRequired { + numAppearanceLayersConverted + numAppearanceLayersTotal + + numPetLayersConverted: numAppearanceLayersConverted(type: PET_LAYER) + numPetLayersTotal: numAppearanceLayersTotal(type: PET_LAYER) + + numItemLayersConverted: numAppearanceLayersConverted(type: ITEM_LAYER) + numItemLayersTotal: numAppearanceLayersTotal(type: ITEM_LAYER) + } + `, + { onError: (e) => console.error(e) } + ); + + return ( + + HTML5 Conversion Hub + + + + + + + {error && ( + + Oops, we couldn't load the latest data: {error.message} + + )} + + ); +} + +function ConversionProgress({ + label, + color, + size, + numConverted, + numTotal, + isLoading, +}) { + const convertedPercent = (numConverted / numTotal) * 100; + + return ( + + + {numConverted != null && numTotal != null && ( + + {Math.floor(convertedPercent)}% + + )} + + + + {label} + {numConverted != null && numTotal != null && ( + + {numConverted.toLocaleString()} of {numTotal.toLocaleString()} + + )} + + + ); +} + +export default ConversionPage; diff --git a/src/app/util.js b/src/app/util.js index e042f9b..90eb05e 100644 --- a/src/app/util.js +++ b/src/app/util.js @@ -84,8 +84,12 @@ export function Heading3({ children, ...props }) { /** * ErrorMessage is a simple error message for simple errors! */ -export function ErrorMessage({ children }) { - return {children}; +export function ErrorMessage({ children, ...props }) { + return ( + + {children} + + ); } export function useCommonStyles() { diff --git a/src/server/loaders.js b/src/server/loaders.js index 6428d1f..352dc96 100644 --- a/src/server/loaders.js +++ b/src/server/loaders.js @@ -709,6 +709,40 @@ const buildSwfAssetLoader = (db) => ); }); +const buildSwfAssetCountLoader = (db) => + new DataLoader( + async (requests) => { + const [rows, _] = await db.execute( + ` + SELECT count(*) AS count, type, + (manifest IS NOT NULL AND manifest != "") AS is_converted + FROM swf_assets + GROUP BY type, is_converted; + ` + ); + const entities = rows.map(normalizeRow); + + return requests.map(({ type, isConverted }) => { + // Find the returned rows that match this count request. + let matchingEntities = entities; + if (type != null) { + matchingEntities = matchingEntities.filter((e) => e.type === type); + } + if (isConverted != null) { + matchingEntities = matchingEntities.filter( + (e) => Boolean(e.isConverted) === isConverted + ); + } + + // Add their counts together, and return the total. + return matchingEntities.map((e) => e.count).reduce((a, b) => a + b, 0); + }); + }, + { + cacheKeyFn: ({ type, isConverted }) => `${type},${isConverted}`, + } + ); + const buildSwfAssetByRemoteIdLoader = (db) => new DataLoader( async (typeAndRemoteIdPairs) => { @@ -1190,6 +1224,7 @@ function buildLoaders(db) { loaders ); loaders.swfAssetLoader = buildSwfAssetLoader(db); + loaders.swfAssetCountLoader = buildSwfAssetCountLoader(db); loaders.swfAssetByRemoteIdLoader = buildSwfAssetByRemoteIdLoader(db); loaders.itemSwfAssetLoader = buildItemSwfAssetLoader(db, loaders); loaders.petSwfAssetLoader = buildPetSwfAssetLoader(db, loaders); diff --git a/src/server/types/AppearanceLayer.js b/src/server/types/AppearanceLayer.js index 1afa7b9..95ac71e 100644 --- a/src/server/types/AppearanceLayer.js +++ b/src/server/types/AppearanceLayer.js @@ -8,6 +8,11 @@ const typeDefs = gql` SIZE_150 } + enum LayerType { + PET_LAYER + ITEM_LAYER + } + # Cache for 1 week (unlikely to change) type AppearanceLayer @cacheControl(maxAge: 604800) { # The DTI ID. Guaranteed unique across all layers of all types. @@ -67,6 +72,18 @@ const typeDefs = gql` """ restrictedZones: [Zone!]! } + + extend type Query { + # Return the number of layers that have been converted to HTML5, optionally + # filtered by type. Cache for 30 minutes (we re-sync with Neopets every + # hour). + numAppearanceLayersConverted(type: LayerType): Int! + @cacheControl(maxAge: 1800) + + # Return the total number of layers, optionally filtered by type. Cache for + # 30 minutes (we re-sync with Neopets every hour). + numAppearanceLayersTotal(type: LayerType): Int! @cacheControl(maxAge: 1800) + } `; const resolvers = { @@ -200,8 +217,39 @@ const resolvers = { return { id: String(rows[0].parent_id) }; }, }, + + Query: { + numAppearanceLayersConverted: async ( + _, + { type }, + { swfAssetCountLoader } + ) => { + const count = await swfAssetCountLoader.load({ + type: convertLayerTypeToSwfAssetType(type), + isConverted: true, + }); + return count; + }, + numAppearanceLayersTotal: async (_, { type }, { swfAssetCountLoader }) => { + const count = await swfAssetCountLoader.load({ + type: convertLayerTypeToSwfAssetType(type), + }); + return count; + }, + }, }; +function convertLayerTypeToSwfAssetType(layerType) { + switch (layerType) { + case "PET_LAYER": + return "biology"; + case "ITEM_LAYER": + return "object"; + default: + return null; + } +} + async function loadAndCacheAssetManifest(db, layer) { let manifest; try {