@@ -0,0 +1,3 @@ | |||||
{ | |||||
"extends": "next/core-web-vitals" | |||||
} |
@@ -0,0 +1,37 @@ | |||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. | |||||
# dependencies | |||||
/node_modules | |||||
/.pnp | |||||
.pnp.js | |||||
# testing | |||||
/coverage | |||||
# next.js | |||||
/.next/ | |||||
/out/ | |||||
# production | |||||
/build | |||||
# misc | |||||
.DS_Store | |||||
*.pem | |||||
# debug | |||||
npm-debug.log* | |||||
yarn-debug.log* | |||||
yarn-error.log* | |||||
.pnpm-debug.log* | |||||
# local env files | |||||
.env*.local | |||||
# vercel | |||||
.vercel | |||||
# typescript | |||||
*.tsbuildinfo | |||||
public/games/ | |||||
.idea/ |
@@ -0,0 +1,34 @@ | |||||
This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). | |||||
## Getting Started | |||||
First, run the development server: | |||||
```bash | |||||
npm run dev | |||||
# or | |||||
yarn dev | |||||
``` | |||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. | |||||
You can start editing the page by modifying `pages/index.tsx`. The page auto-updates as you edit the file. | |||||
[API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.ts`. | |||||
The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages. | |||||
## Learn More | |||||
To learn more about Next.js, take a look at the following resources: | |||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. | |||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. | |||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! | |||||
## Deploy on Vercel | |||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. | |||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. |
@@ -0,0 +1,5 @@ | |||||
/// <reference types="next" /> | |||||
/// <reference types="next/image-types/global" /> | |||||
// NOTE: This file should not be edited | |||||
// see https://nextjs.org/docs/basic-features/typescript for more information. |
@@ -0,0 +1,7 @@ | |||||
/** @type {import('next').NextConfig} */ | |||||
const nextConfig = { | |||||
reactStrictMode: true, | |||||
swcMinify: true, | |||||
} | |||||
module.exports = nextConfig |
@@ -0,0 +1,24 @@ | |||||
{ | |||||
"name": "flash-games", | |||||
"version": "0.1.0", | |||||
"private": true, | |||||
"scripts": { | |||||
"dev": "next dev", | |||||
"build": "next build", | |||||
"start": "next start", | |||||
"lint": "next lint" | |||||
}, | |||||
"dependencies": { | |||||
"next": "12.2.2", | |||||
"react": "18.2.0", | |||||
"react-dom": "18.2.0" | |||||
}, | |||||
"devDependencies": { | |||||
"@types/node": "18.0.3", | |||||
"@types/react": "18.0.15", | |||||
"@types/react-dom": "18.0.6", | |||||
"eslint": "8.19.0", | |||||
"eslint-config-next": "12.2.2", | |||||
"typescript": "4.7.4" | |||||
} | |||||
} |
@@ -0,0 +1,8 @@ | |||||
import '../styles/globals.css' | |||||
import type { AppProps } from 'next/app' | |||||
function MyApp({ Component, pageProps }: AppProps) { | |||||
return <Component {...pageProps} /> | |||||
} | |||||
export default MyApp |
@@ -0,0 +1,10 @@ | |||||
import {NextApiRequest, NextApiResponse} from 'next'; | |||||
import {Game, getAvailableGames} from '../../utils/games'; | |||||
export default async function handler( | |||||
req: NextApiRequest, | |||||
res: NextApiResponse<Game[]> | |||||
) { | |||||
const gameManifests = await getAvailableGames(); | |||||
res.json(gameManifests); | |||||
} |
@@ -0,0 +1,16 @@ | |||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction | |||||
import type { NextApiRequest, NextApiResponse } from 'next' | |||||
import {Game, getAvailableGames} from '../../../utils/games'; | |||||
export default async function handler( | |||||
req: NextApiRequest, | |||||
res: NextApiResponse<Game> | |||||
) { | |||||
const gameManifests = await getAvailableGames(); | |||||
const game = gameManifests.find((g) => g.id === req.query.id); | |||||
if (game) { | |||||
res.json(game) | |||||
return | |||||
} | |||||
res.status(404) | |||||
} |
@@ -0,0 +1,15 @@ | |||||
import type { NextPage } from 'next' | |||||
import {GetServerSideProps} from 'next'; | |||||
const GameLandingPage: NextPage = () => null | |||||
export const getServerSideProps: GetServerSideProps = async (ctx) => { | |||||
return { | |||||
redirect: { | |||||
destination: `/games/${ctx.query.id}/play`, | |||||
permanent: false, | |||||
}, | |||||
}; | |||||
} | |||||
export default GameLandingPage |
@@ -0,0 +1,109 @@ | |||||
import {GetServerSideProps, NextPage} from 'next'; | |||||
import Head from 'next/head'; | |||||
import {useRouter} from 'next/router'; | |||||
import {useEffect, useMemo, useRef, useState} from 'react'; | |||||
import {Game} from '../../../utils/games'; | |||||
type GamePageProps = { | |||||
game: Game, | |||||
} | |||||
const GamePlayPage: NextPage<GamePageProps> = ({ | |||||
game, | |||||
}) => { | |||||
const router = useRouter(); | |||||
const [wrapperWidth, setWrapperWidth] = useState<number>(); | |||||
const { id } = router.query; | |||||
const timeoutRef = useRef(null as NodeJS.Timeout | null); | |||||
const aspectRatio = useMemo(() => game.data.aspectRatio, [game]); | |||||
useEffect(() => { | |||||
const handleResize = () => { | |||||
const currentAspectRatio = window.innerWidth / window.innerHeight; | |||||
if (currentAspectRatio > aspectRatio) { | |||||
if (timeoutRef.current) { | |||||
window.clearTimeout(timeoutRef.current); | |||||
} | |||||
timeoutRef.current = setTimeout(() => { | |||||
setWrapperWidth(window.innerHeight * aspectRatio); | |||||
}, 50); | |||||
} else { | |||||
setWrapperWidth(undefined); | |||||
} | |||||
} | |||||
window.addEventListener('resize', handleResize); | |||||
handleResize(); | |||||
return () => { | |||||
window.removeEventListener('resize', handleResize); | |||||
} | |||||
}, [aspectRatio]); | |||||
if (id) { | |||||
return ( | |||||
<> | |||||
<Head> | |||||
<title>{game.name}</title> | |||||
</Head> | |||||
<div | |||||
style={{ | |||||
width: '100%', | |||||
height: '100%', | |||||
display: 'flex', | |||||
justifyContent: 'center', | |||||
alignItems: 'center', | |||||
position: 'fixed', | |||||
top: 0, | |||||
left: 0, | |||||
backgroundColor: 'black', | |||||
}} | |||||
> | |||||
<div | |||||
style={{ | |||||
width: wrapperWidth ?? '100%', | |||||
paddingBottom: `${100 / aspectRatio}%`, | |||||
position: 'relative', | |||||
}} | |||||
> | |||||
<object | |||||
style={{ | |||||
width: '100%', | |||||
height: '100%', | |||||
position: 'absolute', | |||||
top: 0, | |||||
left: 0, | |||||
objectPosition: 'center', | |||||
}} | |||||
> | |||||
<embed | |||||
src={`/games/${id}/${game.main}`} | |||||
width="100%" | |||||
height="100%" | |||||
/> | |||||
</object> | |||||
</div> | |||||
</div> | |||||
</> | |||||
); | |||||
} | |||||
return null; | |||||
} | |||||
export const getServerSideProps: GetServerSideProps = async (ctx) => { | |||||
const response = await fetch(`http://localhost:3000/api/games/${ctx.query.id}`); | |||||
if (response.status !== 200) { | |||||
return { | |||||
notFound: true, | |||||
} | |||||
} | |||||
const game = await response.json(); | |||||
return { | |||||
props: { | |||||
game, | |||||
} | |||||
} | |||||
} | |||||
export default GamePlayPage; |
@@ -0,0 +1,130 @@ | |||||
import type { NextPage } from 'next' | |||||
import {GetServerSideProps} from 'next'; | |||||
import {Game} from '../../utils/games'; | |||||
import Link from 'next/link'; | |||||
import Head from 'next/head'; | |||||
import Image from 'next/image'; | |||||
type HomeProps = { | |||||
games: Game[], | |||||
} | |||||
const GamesPage: NextPage<HomeProps> = ({ | |||||
games, | |||||
}) => { | |||||
return ( | |||||
<> | |||||
<Head> | |||||
<title>Flash Games</title> | |||||
<style>{` | |||||
body { | |||||
background-color: #eee; | |||||
} | |||||
@media (min-width: 720px) { | |||||
.games { | |||||
grid-template-columns: 1fr 1fr; | |||||
} | |||||
} | |||||
.foo { | |||||
object-fit: cover; | |||||
object-position: center; | |||||
} | |||||
`}</style> | |||||
</Head> | |||||
<div | |||||
style={{ | |||||
width: '100%', | |||||
maxWidth: 720, | |||||
margin: '0 auto', | |||||
padding: '0 2rem', | |||||
boxSizing: 'border-box', | |||||
}} | |||||
> | |||||
<ul | |||||
className="games" | |||||
style={{ | |||||
padding: 0, | |||||
margin: '2rem 0', | |||||
display: 'grid', | |||||
gap: '2rem', | |||||
gridGap: '2rem', | |||||
}} | |||||
> | |||||
{ | |||||
games.map((g) => ( | |||||
<li | |||||
key={g.id} | |||||
style={{ | |||||
display: 'block', | |||||
}} | |||||
> | |||||
<Link | |||||
href={{ | |||||
pathname: '/games/[id]', | |||||
query: { | |||||
id: g.id, | |||||
}, | |||||
}} | |||||
passHref | |||||
> | |||||
<a | |||||
href={`/games/${g.id}`} | |||||
style={{ | |||||
display: 'block', | |||||
height: '12rem', | |||||
position: 'relative', | |||||
overflow: 'hidden', | |||||
borderRadius: '0.25rem', | |||||
border: '1px solid #ddd', | |||||
boxShadow: '0.125rem 0.25rem 0.25rem rgba(0,0,0,0.0625)' | |||||
}} | |||||
> | |||||
<Image | |||||
alt={g.name} | |||||
src={`/games/${g.id}/${g.thumbnail}`} | |||||
layout="fill" | |||||
className="foo" | |||||
/> | |||||
<div | |||||
style={{ | |||||
position: 'absolute', | |||||
bottom: 0, | |||||
left: 0, | |||||
width: '100%', | |||||
textAlign: 'center', | |||||
backgroundColor: 'white', | |||||
padding: '0.5rem 0', | |||||
boxSizing: 'border-box', | |||||
}} | |||||
> | |||||
{g.name} | |||||
</div> | |||||
</a> | |||||
</Link> | |||||
</li> | |||||
)) | |||||
} | |||||
</ul> | |||||
</div> | |||||
</> | |||||
) | |||||
} | |||||
export const getServerSideProps: GetServerSideProps = async (ctx) => { | |||||
const response = await fetch(`http://localhost:3000/api/games`); | |||||
if (response.status !== 200) { | |||||
return { | |||||
notFound: true, | |||||
} | |||||
} | |||||
const games = await response.json(); | |||||
return { | |||||
props: { | |||||
games, | |||||
} | |||||
} | |||||
} | |||||
export default GamesPage |
@@ -0,0 +1,15 @@ | |||||
import type { NextPage } from 'next' | |||||
import {GetServerSideProps} from 'next'; | |||||
const HomePage: NextPage = () => null | |||||
export const getServerSideProps: GetServerSideProps = async () => { | |||||
return { | |||||
redirect: { | |||||
destination: '/games', | |||||
permanent: false, | |||||
}, | |||||
}; | |||||
} | |||||
export default HomePage |
@@ -0,0 +1,4 @@ | |||||
<svg width="283" height="64" viewBox="0 0 283 64" fill="none" | |||||
xmlns="http://www.w3.org/2000/svg"> | |||||
<path d="M141.04 16c-11.04 0-19 7.2-19 18s8.96 18 20 18c6.67 0 12.55-2.64 16.19-7.09l-7.65-4.42c-2.02 2.21-5.09 3.5-8.54 3.5-4.79 0-8.86-2.5-10.37-6.5h28.02c.22-1.12.35-2.28.35-3.5 0-10.79-7.96-17.99-19-17.99zm-9.46 14.5c1.25-3.99 4.67-6.5 9.45-6.5 4.79 0 8.21 2.51 9.45 6.5h-18.9zM248.72 16c-11.04 0-19 7.2-19 18s8.96 18 20 18c6.67 0 12.55-2.64 16.19-7.09l-7.65-4.42c-2.02 2.21-5.09 3.5-8.54 3.5-4.79 0-8.86-2.5-10.37-6.5h28.02c.22-1.12.35-2.28.35-3.5 0-10.79-7.96-17.99-19-17.99zm-9.45 14.5c1.25-3.99 4.67-6.5 9.45-6.5 4.79 0 8.21 2.51 9.45 6.5h-18.9zM200.24 34c0 6 3.92 10 10 10 4.12 0 7.21-1.87 8.8-4.92l7.68 4.43c-3.18 5.3-9.14 8.49-16.48 8.49-11.05 0-19-7.2-19-18s7.96-18 19-18c7.34 0 13.29 3.19 16.48 8.49l-7.68 4.43c-1.59-3.05-4.68-4.92-8.8-4.92-6.07 0-10 4-10 10zm82.48-29v46h-9V5h9zM36.95 0L73.9 64H0L36.95 0zm92.38 5l-27.71 48L73.91 5H84.3l17.32 30 17.32-30h10.39zm58.91 12v9.69c-1-.29-2.06-.49-3.2-.49-5.81 0-10 4-10 10V51h-9V17h9v9.2c0-5.08 5.91-9.2 13.2-9.2z" fill="#000"/> | |||||
</svg> |
@@ -0,0 +1,116 @@ | |||||
.container { | |||||
padding: 0 2rem; | |||||
} | |||||
.main { | |||||
min-height: 100vh; | |||||
padding: 4rem 0; | |||||
flex: 1; | |||||
display: flex; | |||||
flex-direction: column; | |||||
justify-content: center; | |||||
align-items: center; | |||||
} | |||||
.footer { | |||||
display: flex; | |||||
flex: 1; | |||||
padding: 2rem 0; | |||||
border-top: 1px solid #eaeaea; | |||||
justify-content: center; | |||||
align-items: center; | |||||
} | |||||
.footer a { | |||||
display: flex; | |||||
justify-content: center; | |||||
align-items: center; | |||||
flex-grow: 1; | |||||
} | |||||
.title a { | |||||
color: #0070f3; | |||||
text-decoration: none; | |||||
} | |||||
.title a:hover, | |||||
.title a:focus, | |||||
.title a:active { | |||||
text-decoration: underline; | |||||
} | |||||
.title { | |||||
margin: 0; | |||||
line-height: 1.15; | |||||
font-size: 4rem; | |||||
} | |||||
.title, | |||||
.description { | |||||
text-align: center; | |||||
} | |||||
.description { | |||||
margin: 4rem 0; | |||||
line-height: 1.5; | |||||
font-size: 1.5rem; | |||||
} | |||||
.code { | |||||
background: #fafafa; | |||||
border-radius: 5px; | |||||
padding: 0.75rem; | |||||
font-size: 1.1rem; | |||||
font-family: Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono, | |||||
Bitstream Vera Sans Mono, Courier New, monospace; | |||||
} | |||||
.grid { | |||||
display: flex; | |||||
align-items: center; | |||||
justify-content: center; | |||||
flex-wrap: wrap; | |||||
max-width: 800px; | |||||
} | |||||
.card { | |||||
margin: 1rem; | |||||
padding: 1.5rem; | |||||
text-align: left; | |||||
color: inherit; | |||||
text-decoration: none; | |||||
border: 1px solid #eaeaea; | |||||
border-radius: 10px; | |||||
transition: color 0.15s ease, border-color 0.15s ease; | |||||
max-width: 300px; | |||||
} | |||||
.card:hover, | |||||
.card:focus, | |||||
.card:active { | |||||
color: #0070f3; | |||||
border-color: #0070f3; | |||||
} | |||||
.card h2 { | |||||
margin: 0 0 1rem 0; | |||||
font-size: 1.5rem; | |||||
} | |||||
.card p { | |||||
margin: 0; | |||||
font-size: 1.25rem; | |||||
line-height: 1.5; | |||||
} | |||||
.logo { | |||||
height: 1em; | |||||
margin-left: 0.5rem; | |||||
} | |||||
@media (max-width: 600px) { | |||||
.grid { | |||||
width: 100%; | |||||
flex-direction: column; | |||||
} | |||||
} |
@@ -0,0 +1,16 @@ | |||||
html, | |||||
body { | |||||
padding: 0; | |||||
margin: 0; | |||||
font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, | |||||
Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif; | |||||
} | |||||
a { | |||||
color: inherit; | |||||
text-decoration: none; | |||||
} | |||||
* { | |||||
box-sizing: border-box; | |||||
} |
@@ -0,0 +1,20 @@ | |||||
{ | |||||
"compilerOptions": { | |||||
"target": "es5", | |||||
"lib": ["dom", "dom.iterable", "esnext"], | |||||
"allowJs": true, | |||||
"skipLibCheck": true, | |||||
"strict": true, | |||||
"forceConsistentCasingInFileNames": true, | |||||
"noEmit": true, | |||||
"esModuleInterop": true, | |||||
"module": "esnext", | |||||
"moduleResolution": "node", | |||||
"resolveJsonModule": true, | |||||
"isolatedModules": true, | |||||
"jsx": "preserve", | |||||
"incremental": true | |||||
}, | |||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], | |||||
"exclude": ["node_modules"] | |||||
} |
@@ -0,0 +1,32 @@ | |||||
import {readdir, readFile, stat} from 'fs/promises'; | |||||
import {join} from 'path'; | |||||
const BASE_PATH = 'public/games'; | |||||
export type Game = { | |||||
id: string, | |||||
name: string, | |||||
main: string, | |||||
thumbnail: string, | |||||
data: { | |||||
aspectRatio: number, | |||||
}, | |||||
} | |||||
export const getAvailableGames = async (): Promise<Game[]> => { | |||||
const dirs = await readdir(BASE_PATH); | |||||
const dirStat = await Promise.all(dirs.map(async (d) => ({ | |||||
id: d, | |||||
stat: await stat(join(BASE_PATH, d)), | |||||
}))); | |||||
const gameStat = dirStat.filter((d) => d.stat.isDirectory()); | |||||
return Promise.all(gameStat.map(async (g) => { | |||||
const manifestBuffer = await readFile(join(BASE_PATH, g.id, 'manifest.json')); | |||||
const manifestString = manifestBuffer.toString('utf-8'); | |||||
const manifestJson = JSON.parse(manifestString); | |||||
return { | |||||
...manifestJson, | |||||
id: g.id, | |||||
}; | |||||
})); | |||||
} |