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 {