diff --git a/package.json b/package.json index 74b91cd..4d49d61 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,8 @@ }, "dependencies": { "@apollo/client": "^3.13.4", + "@graphql-tools/merge": "^9.0.22", + "@graphql-tools/schema": "^10.0.21", "apollo-server-micro": "^3.13.0", "graphql": "^16.10.0", "micro": "^10.0.1", diff --git a/public/emulator.html b/public/emulator.html index b7af0d7..9140bfd 100644 --- a/public/emulator.html +++ b/public/emulator.html @@ -63,12 +63,16 @@ } EJS_defaultOptions = { + }; const emulatorInit = data => { EJS_gameUrl = `/api/v1/rom?id=${data.gameId}`; EJS_core = data.core; EJS_gameName = data.gameName; + EJS_externalFiles = { + '/homeplay/saves/': `/api/v1/save?id=${data.gameId}` + } scriptElement = document.createElement('script'); scriptElement.src = 'https://cdn.emulatorjs.org/stable/data/loader.js'; @@ -103,6 +107,31 @@ }; EJS_ready = () => { + // Modify the EJS stuff + const oldGetRetroArchCfg = window.EJS_GameManager.prototype.getRetroArchCfg; + const oldMountFileSystems = window.EJS_GameManager.prototype.mountFileSystems; + + EJS_GameManager.prototype.getRetroArchCfg = function() { + const ejsConfig = oldGetRetroArchCfg.call(this); + + // Parse back into key value pair + const raConfig = ejsConfig.split('\n').reduce((acc, line) => { + if(!line.length) return acc; + const [key, value] = line.split('='); + acc[key.trim()] = value.trim(); + return acc; + }, {}); + + // Add new options + raConfig['savefiles_in_content_dir'] = false; + raConfig['sort_savefiles_by_content_enable'] = false; + raConfig['sort_savefiles_enable'] = false; + raConfig['savefile_directory'] = '/homeplay/saves'; + + // Return back as RA config string + return Object.entries(raConfig).map(([k, v]) => `${k} = ${v}`).join('\n'); + } + send({ message: 'ready' }); } diff --git a/src/components/Emulator.tsx b/src/components/Emulator.tsx index 25bb5cf..e45aa31 100644 --- a/src/components/Emulator.tsx +++ b/src/components/Emulator.tsx @@ -1,18 +1,11 @@ import { useEffect, useRef, useState } from 'react'; import styles from './Emulator.module.scss'; - -type EmulatorSystem = ( - 'gb' | 'gbc' | 'gba' -); - -type EmulatorCore = ( - 'gambatte' | 'mgba' -); +import { GAME_SYSTEM_CORES, GameSystem, GameSystemCore } from '@/lib/game'; type SendEmulatorMessageInit = { message:'init'; - core:EmulatorCore; - system:EmulatorSystem; + core:GameSystemCore; + system:GameSystem; gameId:string; gameName:string; } @@ -35,13 +28,9 @@ type ReceiveEmulatorMessage = ( ); export type EmulatorProps = { - system:EmulatorSystem; -}; - -const EMULATOR_SYSTEM_CORES:{ [key in EmulatorSystem]:EmulatorCore } = { - 'gb': 'gambatte', - 'gbc': 'gambatte', - 'gba': 'mgba' + system:GameSystem; + gameId:string; + gameName:string; }; export const Emulator:React.FC = props => { @@ -58,13 +47,13 @@ export const Emulator:React.FC = props => { send({ message: 'init', - core: EMULATOR_SYSTEM_CORES[props.system], + core: GAME_SYSTEM_CORES[props.system], system: props.system, - gameId: '0', - gameName: 'Pokemon - Crystal Version' + gameId: props.gameId, + gameName: props.gameName }); - window.onmessage = (e) => { + window.onmessage = e => { if(!e.data) { console.error('Invalid message received:', e.data); return; diff --git a/src/graphql/game.ts b/src/graphql/game.ts new file mode 100644 index 0000000..f3d5f8b --- /dev/null +++ b/src/graphql/game.ts @@ -0,0 +1,77 @@ +import { GAME_SYSTEMS } from "@/lib/game"; +import { ApolloError, gql } from "apollo-server-micro"; +import { pageTypeDefs } from "./page"; + +export const gameTypeDefs = gql` + ${pageTypeDefs} + + enum GameSystem { + ${GAME_SYSTEMS.join('\n')} + } + + type Game { + id: ID! + name: String! + system: GameSystem! + } + + input GameFilter { + system: GameSystem + } + + enum GameOrderBy { + NAME_ASC + NAME_DESC + } + + type GameEdge { + node: Game + cursor: String + } + + type GameConnection { + edges: [GameEdge] + pageInfo: PageInfo + } + + type Query { + game(id: ID!): Game + games(first: Int!, after: String, orderBy: GameOrderBy, filter: GameFilter): GameConnection + } +`; + +const GAME = { + id: '0', + name: 'Pokemon Crystal Version', + system: 'gbc' +}; + +export const gameResolvers = { + Query: { + game: (_:any, { id }:{ id:string }) => { + if(!id) throw new ApolloError('Invalid ID'); + + switch(id) { + case '0': + return GAME; + + default: + return null; + } + }, + + games: (_: any, { first, after, orderBy, filter }: { first: number, after?: string, orderBy?: string, filter?: { system?: string } }) => { + if(first > 100) throw new ApolloError('first cannot be greater than 100'); + return { + edges: [ + { node: GAME } + ], + pageInfo: { + hasNextPage: false, + hasPreviousPage: false, + startCursor: '0' + }, + }; + }, + }, +}; \ No newline at end of file diff --git a/src/graphql/page.ts b/src/graphql/page.ts new file mode 100644 index 0000000..1c13f7b --- /dev/null +++ b/src/graphql/page.ts @@ -0,0 +1,10 @@ +import { gql } from "apollo-server-micro"; + +export const pageTypeDefs = gql` + type PageInfo { + endCursor: String + hasNextPage: Boolean! + hasPreviousPage: Boolean! + startCursor: String + } +`; \ No newline at end of file diff --git a/src/graphql/schema.ts b/src/graphql/schema.ts index fca7bb1..a26f04d 100644 --- a/src/graphql/schema.ts +++ b/src/graphql/schema.ts @@ -1,13 +1,17 @@ import { gql } from 'apollo-server-micro'; +import { gameTypeDefs, gameResolvers } from './game'; +import { mergeTypeDefs } from '@graphql-tools/merge'; +import { makeExecutableSchema } from '@graphql-tools/schema'; -export const typeDefs = gql` - type Query { - hello: String - } -`; +export const typeDefs = mergeTypeDefs([ + gameTypeDefs +]); -export const resolvers = { - Query: { - hello: () => 'Very cool!', - }, -}; +export const resolvers = [ + gameResolvers +]; + +export const schema = makeExecutableSchema({ + typeDefs, + resolvers +}); \ No newline at end of file diff --git a/src/lib/fragment.ts b/src/lib/fragment.ts new file mode 100644 index 0000000..a7a7048 --- /dev/null +++ b/src/lib/fragment.ts @@ -0,0 +1,29 @@ +import { gql } from "apollo-server-micro"; +import { DocumentNode } from "graphql"; + +type FragmentDefinition = { + query:DocumentNode; + name:string; + needsInclude:boolean; +} + +export const createFragment = (params:{ + query:DocumentNode; + name:string; + needsInclude?:boolean; +}):FragmentDefinition => { + return { + ...params, + needsInclude: typeof params.needsInclude === 'undefined' ? true : params.needsInclude + } +} + +export const includeFragment = (fragment:FragmentDefinition) => { + if(!fragment.needsInclude) return ''; + return fragment.query; +} + +export const extendFragment = (fragment:FragmentDefinition) => { + if(!fragment.needsInclude) return fragment.query; + return `...${fragment.name}`; +} \ No newline at end of file diff --git a/src/lib/game.ts b/src/lib/game.ts new file mode 100644 index 0000000..8d36f3f --- /dev/null +++ b/src/lib/game.ts @@ -0,0 +1,45 @@ +import { createFragment } from "@/lib/fragment"; +import { gql } from "apollo-server-micro"; + +export const GAME_SYSTEM_CORES = { + 'gb': 'gambatte', + 'gbc': 'gambatte', + 'gba': 'mgba' +}; + +export type GameSystem = keyof typeof GAME_SYSTEM_CORES; +export type GameSystemCore = typeof GAME_SYSTEM_CORES[GameSystem]; + +export type GameLight = { + id:string; + name:string; + system:GameSystem; +}; + +export const GameLightFragment = createFragment({ + name: `GameLight`, + query: gql` + fragment GameLight on Game { + id + name + system + } + ` +}); + +export type GameHeavy = { + id:string; + name:string; + system:GameSystem; +}; + +export const GameHeavyFragment = createFragment({ + name: `GameHeavy`, + query: gql` + fragment GameHeavy on Game { + id + name + system + } + ` +}); \ No newline at end of file diff --git a/src/lib/page.ts b/src/lib/page.ts new file mode 100644 index 0000000..8dd85a6 --- /dev/null +++ b/src/lib/page.ts @@ -0,0 +1,30 @@ +import { gql } from "apollo-server-micro"; +import { DocumentNode } from "graphql"; + +export type Paginated = { + edges: { node: T }[]; + pageInfo:{ + startCursor:string|null; + endCursor:string|null; + hasNextPage:boolean; + hasPreviousPage:boolean; + }; +}; + +export const paginationQuery = (nodeDefs:DocumentNode|string) => { + return ` + edges { + cursor + node { + ${nodeDefs} + } + } + + pageInfo { + startCursor + endCursor + hasNextPage + hasPreviousPage + } + `; +}; \ No newline at end of file diff --git a/src/pages/404.tsx b/src/pages/404.tsx new file mode 100644 index 0000000..da418cc --- /dev/null +++ b/src/pages/404.tsx @@ -0,0 +1,13 @@ +export type Error404Props = { + error:404; +}; + +export const Error404:React.FC = props => { + return ( +
+

Error 404

+
+ ); +} + +export default Error404; \ No newline at end of file diff --git a/src/pages/500.tsx b/src/pages/500.tsx new file mode 100644 index 0000000..f6d8da7 --- /dev/null +++ b/src/pages/500.tsx @@ -0,0 +1,14 @@ +export type Error500Props = { + code:500; +}; + +export const Error500:React.FC = props => { + console.log('props', props); + return ( +
+

Error 500

+
+ ) +} + +export default Error500; \ No newline at end of file diff --git a/src/pages/api/v1/graphql.ts b/src/pages/api/v1/graphql.ts index c3a5053..d3cb757 100644 --- a/src/pages/api/v1/graphql.ts +++ b/src/pages/api/v1/graphql.ts @@ -1,8 +1,8 @@ -import { typeDefs, resolvers } from "@/graphql/schema"; +import { typeDefs, schema } from "@/graphql/schema"; import { ApolloServer } from "apollo-server-micro"; import { NextApiRequest, NextApiResponse } from "next"; -const apolloServer = new ApolloServer({ typeDefs, resolvers }); +const apolloServer = new ApolloServer({ typeDefs, schema }); export const config = { api: { diff --git a/src/pages/api/v1/save.ts b/src/pages/api/v1/save.ts new file mode 100644 index 0000000..4d2f8bc --- /dev/null +++ b/src/pages/api/v1/save.ts @@ -0,0 +1,44 @@ +import { NextApiHandler } from "next"; +import * as fs from 'fs'; +import * as path from 'path'; + +const PATH_SAVES = path.resolve('.', 'data', 'saves'); + +const handler:NextApiHandler = async (req, res) => { + if (req.method !== 'GET') { + res.setHeader('Allow', ['GET']); + res.status(405).end(`Method Not Allowed`); + return; + } + + // ID query param + if(!req.query.id || typeof req.query.id !== 'string') { + res.status(404).end(`Not Found`); + return; + } + + const { id } = req.query; + if(!id) { + res.status(404).end(`Not Found`); + return; + } + + // Does rom exist? + const pathSave = path.resolve(PATH_SAVES, `${id}.zip`); + if(!fs.existsSync(pathSave)) { + res.status(404).end(`Not Found`); + return; + } + + // Read save + try { + const romStream = fs.createReadStream(pathSave); + res.setHeader('Content-Type', 'application/octet-stream'); + res.setHeader('Content-Disposition', `attachment; filename="${id}.zip"`); + romStream.pipe(res); + } catch (error) { + res.status(500).end(`Internal Server Error`); + } +}; + +export default handler; \ No newline at end of file diff --git a/src/pages/games/[id]/index.tsx b/src/pages/games/[id]/index.tsx index 4b10426..991d364 100644 --- a/src/pages/games/[id]/index.tsx +++ b/src/pages/games/[id]/index.tsx @@ -1,4 +1,9 @@ -import { GetServerSideProps, GetStaticPaths, GetStaticProps } from 'next'; +import { apiClientGet } from '@/lib/apiClient'; +import { includeFragment, extendFragment } from '@/lib/fragment'; +import { GameHeavy, GameLightFragment } from '@/lib/game'; +import { Error500, Error500Props } from '@/pages/500'; +import { gql } from 'apollo-server-micro'; +import { GetServerSideProps } from 'next'; import Link from 'next/link'; type PageParams = { @@ -6,31 +11,48 @@ type PageParams = { } type PageProps = { - id:string; -} + game:GameHeavy; +} | Error500Props; -export const getServerSideProps:GetServerSideProps<> = async ({ params }) => { - const { id } = params as PageParams; +export const getServerSideProps:GetServerSideProps = async ({ params }) => { + try { + if(!params || !params.id) return { notFound: true }; - // if(id !== '0') { - // return { - // notFound: true - // }; - // } + const client = await apiClientGet(); + const res = await client.query<{ game:GameHeavy }>({ + variables: { id: params.id }, + query: gql` + ${includeFragment(GameLightFragment)} - return { - props: { - id - }, - }; + query getGame($id: ID!) { + game(id: $id) { + ${extendFragment(GameLightFragment)} + } + } + `, + }); + if(!res.data.game) return { notFound: true }; + + return { props: { ...res.data } }; + } catch(e) { + console.error('Error', e); + return { props: { code: 500 } }; + } }; -export const Page:React.FC = ({ id }) => { +export const Page:React.FC = props => { + if('code' in props) { + if(props.code === 500) return ; + return null; + } + + const { game } = props; + return (
-

Viewing Game ID: {id}

+

{ game.name }

- + Play Game
diff --git a/src/pages/games/[id]/play.tsx b/src/pages/games/[id]/play.tsx index 6148a43..9d94e0e 100644 --- a/src/pages/games/[id]/play.tsx +++ b/src/pages/games/[id]/play.tsx @@ -27,7 +27,9 @@ export const Page:React.FC = ({ id }) => {
diff --git a/src/pages/games/index.tsx b/src/pages/games/index.tsx index c15bf6c..76f16ee 100644 --- a/src/pages/games/index.tsx +++ b/src/pages/games/index.tsx @@ -1,25 +1,73 @@ -import { GetServerSideProps, GetStaticPaths, GetStaticProps } from 'next'; +import { GetServerSideProps } from 'next'; import Link from 'next/link'; +import { apiClientGet } from '@/lib/apiClient'; +import { gql } from 'apollo-server-micro'; +import { GameLight, GameLightFragment } from '@/lib/game'; +import Error500, { Error500Props } from '../500'; +import { extendFragment, includeFragment } from '@/lib/fragment'; +import { Paginated, paginationQuery } from '@/lib/page'; -type PageProps = { -} - -export const getServerSideProps:GetServerSideProps = async ({ params }) => { - return { - props: { - }, - }; +type Game = { + id: string; + name: string; + system: string; }; -export const Page:React.FC = ({ }) => { +type PageProps = { + games:Paginated|null; +} | Error500Props; + +export const getServerSideProps: GetServerSideProps = async () => { + try { + const client = await apiClientGet(); + const res = await client.query<{ games:Paginated }>({ + variables: { }, + query: gql` + ${includeFragment(GameLightFragment)} + query getGames { + games(first: 100) { + ${paginationQuery(extendFragment(GameLightFragment))} + } + } + `, + }); + + if(!res || !res.data || !res.data.games) { + return { props: { games:null } }; + } + + return { props: { games: res.data.games } }; + } catch (e) { + console.error('Error', e); + return { props: { code: 500 } }; + } +}; + +export const Page: React.FC = props => { + if ('code' in props) { + if (props.code === 500) return ; + return null; + } + + const { games } = props; + if(!games) { + return
No games found
; + } + return (

Games

-
- - Test Game - -
+
    + {games.edges.map(({ node }) => { + return ( +
  • + + {node.name} ({node.system}) + +
  • + ); + })} +
); }; diff --git a/src/pages/index.tsx b/src/pages/index.tsx index a87c9f9..3ff28c0 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -4,9 +4,11 @@ import { GetServerSideProps } from 'next'; import { apiClientGet } from '@/lib/apiClient'; import { ApolloQueryResult, gql } from '@apollo/client'; import Link from 'next/link'; +import { GameLightFragment, GameLight } from '@/lib/game'; +import { extendFragment, includeFragment } from '@/lib/fragment'; type PageData = { - hello:string; + game:GameLight; } type PageProps = ApolloQueryResult @@ -16,10 +18,17 @@ export const getServerSideProps:GetServerSideProps = async () => { const res = await client.query({ query: gql` - query { - hello + ${includeFragment(GameLightFragment)} + + query getGame($id: ID!) { + game(id: $id) { + ${extendFragment(GameLightFragment)} + } } - ` + `, + variables: { + id: '0' + } }); return { @@ -34,11 +43,10 @@ const HomePage: React.FC = props => { HomePlay, your personal video game library - - Play Game + + Games - - { props.data?.hello } + { JSON.stringify(props) } ); } diff --git a/src/queries/game.ts b/src/queries/game.ts new file mode 100644 index 0000000..e69de29 diff --git a/yarn.lock b/yarn.lock index c2d6a77..2e00899 100644 --- a/yarn.lock +++ b/yarn.lock @@ -152,6 +152,14 @@ "@graphql-tools/utils" "^9.2.1" tslib "^2.4.0" +"@graphql-tools/merge@^9.0.22": + version "9.0.22" + resolved "https://registry.yarnpkg.com/@graphql-tools/merge/-/merge-9.0.22.tgz#f8d316604360021c71d8a9397784f4eb178741fe" + integrity sha512-bjOs9DlTbo1Yz2UzQcJ78Dn9/pKyY2zNaoqNLfRTTSkO56QFkvqhfjQuqJcqu+V3rtaB2o0VMpWaY6JT8ZTvQA== + dependencies: + "@graphql-tools/utils" "^10.8.4" + tslib "^2.4.0" + "@graphql-tools/mock@^8.1.2": version "8.7.20" resolved "https://registry.yarnpkg.com/@graphql-tools/mock/-/mock-8.7.20.tgz#c83ae0f1940d194a3982120c9c85f3ac6b4f7f20" @@ -162,6 +170,15 @@ fast-json-stable-stringify "^2.1.0" tslib "^2.4.0" +"@graphql-tools/schema@^10.0.21": + version "10.0.21" + resolved "https://registry.yarnpkg.com/@graphql-tools/schema/-/schema-10.0.21.tgz#197584222c2fba507dfacf26dce96b0b1f67b746" + integrity sha512-AECSlNnD0WNxICwfJs93gYn2oHxPmztn1MYBETIQXrJJcymfD6BoUrDlYPa6F27RzRc+gbPZPHMWL26uujfKBg== + dependencies: + "@graphql-tools/merge" "^9.0.22" + "@graphql-tools/utils" "^10.8.4" + tslib "^2.4.0" + "@graphql-tools/schema@^8.0.0": version "8.5.1" resolved "https://registry.yarnpkg.com/@graphql-tools/schema/-/schema-8.5.1.tgz#c2f2ff1448380919a330312399c9471db2580b58" @@ -189,6 +206,17 @@ dependencies: tslib "^2.4.0" +"@graphql-tools/utils@^10.8.4": + version "10.8.4" + resolved "https://registry.yarnpkg.com/@graphql-tools/utils/-/utils-10.8.4.tgz#59dc5ea949e26c6bae28b91ad9f5c00e992de072" + integrity sha512-HpHBgcmLIE79jWk1v5Bm0Eb8MaPiwSJT/Iy5xIJ+GMe7yAKpCYrbjf7wb+UMDMkLkfEryvo3syCx8k+TMAZ9bA== + dependencies: + "@graphql-typed-document-node/core" "^3.1.1" + "@whatwg-node/promise-helpers" "^1.0.0" + cross-inspect "1.0.1" + dset "^3.1.4" + tslib "^2.4.0" + "@graphql-tools/utils@^9.2.1": version "9.2.1" resolved "https://registry.yarnpkg.com/@graphql-tools/utils/-/utils-9.2.1.tgz#1b3df0ef166cfa3eae706e3518b17d5922721c57" @@ -568,6 +596,13 @@ dependencies: csstype "^3.0.2" +"@whatwg-node/promise-helpers@^1.0.0": + version "1.2.4" + resolved "https://registry.yarnpkg.com/@whatwg-node/promise-helpers/-/promise-helpers-1.2.4.tgz#4f62d48e27059e8e655add21faa82d177abf5a0c" + integrity sha512-daEUfaHbaMuAcor+FPAVK+pOCSzsAYhK6LN1y81EcakdqQEPQvjm74PTmfwfv8POg8pw4RyCv9LXB1e+mQDwqg== + dependencies: + tslib "^2.6.3" + "@wry/caches@^1.0.0": version "1.0.1" resolved "https://registry.yarnpkg.com/@wry/caches/-/caches-1.0.1.tgz#8641fd3b6e09230b86ce8b93558d44cf1ece7e52" @@ -765,6 +800,13 @@ content-type@1.0.4: resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.4.tgz#e138cc75e040c727b1966fe5e5f8c9aee256fe3b" integrity sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA== +cross-inspect@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/cross-inspect/-/cross-inspect-1.0.1.tgz#15f6f65e4ca963cf4cc1a2b5fef18f6ca328712b" + integrity sha512-Pcw1JTvZLSJH83iiGWt6fRcT+BjZlCDRVwYLbUcHzv/CRpB7r0MlSrGbIyQvVSNyGnbt7G4AXuyCiDR3POvZ1A== + dependencies: + tslib "^2.4.0" + cssfilter@0.0.10: version "0.0.10" resolved "https://registry.yarnpkg.com/cssfilter/-/cssfilter-0.0.10.tgz#c6d2672632a2e5c83e013e6864a42ce8defd20ae" @@ -790,6 +832,11 @@ detect-libc@^2.0.3: resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.0.3.tgz#f0cd503b40f9939b894697d19ad50895e30cf700" integrity sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw== +dset@^3.1.4: + version "3.1.4" + resolved "https://registry.yarnpkg.com/dset/-/dset-3.1.4.tgz#f8eaf5f023f068a036d08cd07dc9ffb7d0065248" + integrity sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA== + fast-json-stable-stringify@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633" @@ -1205,7 +1252,7 @@ ts-invariant@^0.10.3: dependencies: tslib "^2.1.0" -tslib@^2.1.0, tslib@^2.3.0, tslib@^2.4.0, tslib@^2.8.0: +tslib@^2.1.0, tslib@^2.3.0, tslib@^2.4.0, tslib@^2.6.3, tslib@^2.8.0: version "2.8.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==