mirror of
https://github.com/He4eT/elseifplayer.git
synced 2026-05-05 01:17:22 +00:00
Compare commits
No commits in common. "master" and "v0.0.0" have entirely different histories.
59 changed files with 8664 additions and 8780 deletions
56
.eslintrc.js
56
.eslintrc.js
|
|
@ -1,46 +1,26 @@
|
|||
module.exports = {
|
||||
'env': {
|
||||
'browser': true,
|
||||
'es2021': true
|
||||
env: {
|
||||
browser: true,
|
||||
es2021: true
|
||||
},
|
||||
'extends': [
|
||||
'eslint:recommended',
|
||||
'preact',
|
||||
extends: [
|
||||
'standard',
|
||||
'standard-preact'
|
||||
],
|
||||
'overrides': [
|
||||
overrides: [
|
||||
{
|
||||
files: ['*.js', '*.jsx'],
|
||||
},
|
||||
files: ['*.jsx', '*.js']
|
||||
}
|
||||
],
|
||||
'parserOptions': {
|
||||
'ecmaVersion': 'latest',
|
||||
'sourceType': 'module'
|
||||
parserOptions: {
|
||||
ecmaVersion: 12,
|
||||
sourceType: 'module'
|
||||
},
|
||||
'rules': {
|
||||
'jest/no-deprecated-functions': 0,
|
||||
|
||||
'arrow-parens': ['error', 'always'],
|
||||
'comma-dangle': ['error', 'always-multiline'],
|
||||
|
||||
'indent': [
|
||||
'error',
|
||||
2
|
||||
],
|
||||
'linebreak-style': [
|
||||
'error',
|
||||
'unix'
|
||||
],
|
||||
'object-curly-spacing': [
|
||||
'error',
|
||||
'always'
|
||||
],
|
||||
'quotes': [
|
||||
'error',
|
||||
'single'
|
||||
],
|
||||
'semi': [
|
||||
'error',
|
||||
'never'
|
||||
],
|
||||
rules: {
|
||||
},
|
||||
settings: {
|
||||
react: {
|
||||
version: 'latest'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -1,4 +1,4 @@
|
|||
node_modules/
|
||||
|
||||
dist/
|
||||
.parcel-cache/
|
||||
.cache/
|
||||
|
|
|
|||
|
|
@ -1,4 +0,0 @@
|
|||
{
|
||||
"extends": ["@parcel/config-default"],
|
||||
"reporters": ["...", "parcel-reporter-static-files-copy"]
|
||||
}
|
||||
28
CHANGELOG.md
28
CHANGELOG.md
|
|
@ -1,28 +0,0 @@
|
|||
# 0.2.0
|
||||
|
||||
## Breaking Changes
|
||||
|
||||
- Changed direct links format
|
||||
- Changed savefiles format
|
||||
|
||||
## Changes
|
||||
|
||||
- Added over 150 new interface themes
|
||||
- Added themes preview page
|
||||
- Implemented smooth scrolling when a new message appears
|
||||
- Added an in-game menu
|
||||
- Improved mobile user experience
|
||||
- Upgraded Emglken to version 0.5.2
|
||||
- Upgraded cheap-glkote to version 0.5.1
|
||||
- Added support for ADRIFT 4
|
||||
- Fixed broken links
|
||||
- Enhanced error handling
|
||||
- Refactored styles for improved code structure
|
||||
|
||||
# 0.1.0
|
||||
|
||||
- Added support for multiple output buffers
|
||||
|
||||
# 0.0.0
|
||||
|
||||
- Initial release
|
||||
69
README.md
69
README.md
|
|
@ -1,65 +1,18 @@
|
|||
# ElseIFPlayer
|
||||
# ifplayer
|
||||
|
||||
ElseIFPlayer is an interactive fiction player for the web.
|
||||
It's powered by [cheap-glkote](https://github.com/He4eT/cheap-glkote) and [Emglken](https://github.com/curiousdannii/emglken).
|
||||
Interactive Fiction player for the web.
|
||||
Powered by [cheap-glkote](https://github.com/He4eT/cheap-glkote) and [Emglken](https://github.com/curiousdannii/emglken).
|
||||
|
||||
Player available here: [https://he4et.github.io/elseifplayer/](https://he4et.github.io/elseifplayer/).
|
||||
|
||||
## Getting Started
|
||||
|
||||
- Ensure that you have Node.js and NPM installed on your system.
|
||||
- Install the required packages by running the command `npm install` in your project directory.
|
||||
- Launch the local development server using `npm run dev`.
|
||||
|
||||
## Build
|
||||
|
||||
To create a production build, use the following command:
|
||||
```
|
||||
npm run build <public-url>
|
||||
```
|
||||
|
||||
- If you intend to host the player on `https://your.domain/`, use:
|
||||
```
|
||||
npm run build /
|
||||
```
|
||||
- For hosting it in a specific directory like `https://your.domain/some-directory/`, use:
|
||||
```
|
||||
npm run build /some-directory
|
||||
```
|
||||
|
||||
The finalized production bundle will be generated and stored in the `/docs` directory.
|
||||
To see a live demo, check out [https://he4et.github.io/ifplayer/](https://he4et.github.io/ifplayer/).
|
||||
|
||||
## Direct links
|
||||
|
||||
You can provide a direct link to a specific game using the following URL format:
|
||||
```
|
||||
/#/<mode>/<encodedURL>/[theme]/
|
||||
```
|
||||
You can provide the direct link to your game:
|
||||
|
||||
- `mode` specifies the player interface mode:
|
||||
- `play`: the default multi-window mode
|
||||
- `focus`: the single-window mode without additional windows, such as the status bar
|
||||
- `encodedURL` represents the location of the storyfile encoded with `encodeURIComponent`.
|
||||
- `theme` is optional and allows you to choose a specific UI theme.
|
||||
`/#/play/encodedURL/[theme]/`
|
||||
- `encodedURL` - storyfile location encoded with `encodeURIComponent`;
|
||||
- `theme` - [UI theme](https://github.com/He4eT/ifplayer/blob/master/src/themes/themes.js), optional;
|
||||
|
||||
### CORS
|
||||
|
||||
If the player and your storyfile are located on different domains,
|
||||
you need to use appropriate [CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS) settings.
|
||||
|
||||
If you cannot modify the server settings, you can use the [Parchment Proxy](https://iplayif.com/proxy/) as an alternative.
|
||||
|
||||
### Direct Link Examples
|
||||
|
||||
- [Play "Lost Pig" with default or last used theme](https://he4et.github.io/elseifplayer/#/play/https%3A%2F%2Fmirror.ifarchive.org%2Fif-archive%2Fgames%2Fzcode%2FLostPig.z8/)
|
||||
- [Play "Lost Pig" without statusbar with default or last used theme](https://he4et.github.io/elseifplayer/#/focus/https%3A%2F%2Fmirror.ifarchive.org%2Fif-archive%2Fgames%2Fzcode%2FLostPig.z8/)
|
||||
- [Play "Lost Pig" with Nord theme](https://he4et.github.io/elseifplayer/#/play/https%3A%2F%2Fmirror.ifarchive.org%2Fif-archive%2Fgames%2Fzcode%2FLostPig.z8/nord/)
|
||||
- [Play "Lost Pig" without statusbar with Dim theme](https://he4et.github.io/elseifplayer/#/focus/https%3A%2F%2Fmirror.ifarchive.org%2Fif-archive%2Fgames%2Fzcode%2FLostPig.z8/dim/)
|
||||
- [Play "Lost Pig" loaded with Parchment Proxy](https://he4et.github.io/elseifplayer/#/play/https%3A%2F%2Fiplayif.com%2Fproxy%2F%3Furl%3Dhttps%3A%2F%2Fifarchive.org%2Fif-archive%2Fgames%2Fzcode%2FLostPig.z8)
|
||||
|
||||
## License
|
||||
|
||||
ElseIFPlayer is distributed under the MIT License.
|
||||
However, please remember to respect the licenses of the interpreters
|
||||
listed on the
|
||||
[Emglken page](https://github.com/curiousdannii/emglken#included-projects).
|
||||
### Examples
|
||||
- [Play "Lost Pig" with default or last used theme](https://he4et.github.io/ifplayer/#/play/https%3A%2F%2Fmirror.ifarchive.org%2Fif-archive%2Fgames%2Fzcode%2FLostPig.z8/);
|
||||
- [Play "Lost Pig" with Nord theme](https://he4et.github.io/ifplayer/#/play/https%3A%2F%2Fmirror.ifarchive.org%2Fif-archive%2Fgames%2Fzcode%2FLostPig.z8/nord/);
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
CURRENT_TIMESTAMP=`date +"%Y-%m-%d-%H%M%S"`
|
||||
|
||||
GH_REPO_NAME='elseifplayer'
|
||||
GH_REPO_NAME='ifplayer'
|
||||
RELEASE_BRANCH='release'
|
||||
BUILD_DIR='docs'
|
||||
|
||||
|
|
|
|||
22
index.html
22
index.html
|
|
@ -4,25 +4,33 @@
|
|||
<meta charset="UTF-8">
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, interactive-widget=resizes-content">
|
||||
content="width=device-width, initial-scale=1.0">
|
||||
<title>
|
||||
ElseIFPlayer
|
||||
IFPlayer
|
||||
</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Interactive Fiction player for the web">
|
||||
content="Interactive Fiction player for the web.">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<div id="root">
|
||||
|
||||
<script type="module" src="./src/index.js"></script>
|
||||
<div class="app play">
|
||||
<main>
|
||||
<div class="status loading">
|
||||
<div>Loading</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script src="./src/index.js"></script>
|
||||
|
||||
<!-- <goatcounter> -->
|
||||
<script>
|
||||
window.goatcounter = { no_onload: true }
|
||||
window.addEventListener('hashchange', _ => {
|
||||
if (!window.goatcounter.count) return void null
|
||||
|
||||
window.goatcounter.count({
|
||||
path: location.pathname + location.hash
|
||||
})
|
||||
|
|
|
|||
13795
package-lock.json
generated
13795
package-lock.json
generated
File diff suppressed because it is too large
Load diff
45
package.json
45
package.json
|
|
@ -1,44 +1,35 @@
|
|||
{
|
||||
"name": "elseifplayer",
|
||||
"version": "0.2.0",
|
||||
"name": "ifplayer",
|
||||
"version": "0.1.0",
|
||||
"description": "Play interactive fiction games in your browser",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev": "parcel index.html",
|
||||
"build": "parcel build index.html --dist-dir docs --public-url",
|
||||
"build": "parcel build index.html --out-dir docs --public-url",
|
||||
"lint": "eslint --fix src"
|
||||
},
|
||||
"author": "He4eT",
|
||||
"license": "MIT",
|
||||
"browserslist": "defaults",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
},
|
||||
"alias": {
|
||||
"preact/jsx-dev-runtime": "preact/jsx-runtime"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@parcel/transformer-sass": "^2.9.3",
|
||||
"buffer": "^6.0.3",
|
||||
"crypto-browserify": "^3.12.0",
|
||||
"eslint": "^8.44.0",
|
||||
"eslint-config-preact": "^1.3.0",
|
||||
"events": "^3.3.0",
|
||||
"parcel": "^2.9.3",
|
||||
"parcel-reporter-static-files-copy": "^1.5.0",
|
||||
"path-browserify": "^1.0.1",
|
||||
"process": "^0.11.10",
|
||||
"stream-browserify": "^3.0.0"
|
||||
"eslint": "^7.20.0",
|
||||
"eslint-config-standard": "^16.0.2",
|
||||
"eslint-config-standard-preact": "^1.1.6",
|
||||
"eslint-plugin-import": "^2.22.1",
|
||||
"eslint-plugin-node": "^11.1.0",
|
||||
"eslint-plugin-promise": "^4.3.1",
|
||||
"parcel-bundler": "^1.12.4",
|
||||
"parcel-plugin-static-files-copy": "^2.5.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/open-sans": "^5.0.3",
|
||||
"base32768": "^3.0.1",
|
||||
"cheap-glkote": "^0.5.1",
|
||||
"emglken": "^0.5.2",
|
||||
"preact": "^10.15.1",
|
||||
"@fontsource/open-sans": "^4.2.1",
|
||||
"cheap-glkote": "^0.2.5",
|
||||
"emglken": "^0.3.3",
|
||||
"lz-string": "^1.4.4",
|
||||
"preact": "^10.5.12",
|
||||
"wouter-preact": "^2.7.3"
|
||||
},
|
||||
"staticFiles": {
|
||||
"staticPath": "node_modules/emglken/build",
|
||||
"staticOutPath": "emglken"
|
||||
"excludeGlob": "*.js"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
76
src/App.jsx
76
src/App.jsx
|
|
@ -1,76 +0,0 @@
|
|||
import { Route, Router, Switch } from 'wouter-preact'
|
||||
|
||||
import {
|
||||
useHashLocation,
|
||||
extractView,
|
||||
} from './routing'
|
||||
import {
|
||||
useThemeEngine,
|
||||
} from '~/src/themes/themes'
|
||||
|
||||
import HomeView from '~/src/views/HomeView/HomeView'
|
||||
import GamesView from '~/src/views/GamesView/GamesView'
|
||||
import ThemesView from '~/src/views/ThemesView/ThemesView'
|
||||
import PlayerView from '~/src/views/PlayerView/PlayerView'
|
||||
import NotFoundView from '~/src/views/NotFoundView/NotFoundView'
|
||||
|
||||
import * as s from './style/App.module.scss'
|
||||
|
||||
export default function App () {
|
||||
const themeEngine = useThemeEngine()
|
||||
const [currentLocation] = useHashLocation()
|
||||
|
||||
const playerView = (themeEngine, singleWindow) =>
|
||||
function view (params) {
|
||||
return (<PlayerView {...{
|
||||
themeEngine,
|
||||
singleWindow,
|
||||
...params,
|
||||
}} />)
|
||||
}
|
||||
|
||||
return (
|
||||
<Router hook={useHashLocation}>
|
||||
<div className={[
|
||||
s.app,
|
||||
s[extractView(currentLocation)],
|
||||
themeEngine.currentTheme,
|
||||
].join(' ')}>
|
||||
|
||||
<Switch>
|
||||
<Route path='/'>
|
||||
<HomeView {...{
|
||||
themeEngine,
|
||||
}} />
|
||||
</Route>
|
||||
<Route path='/games/'>
|
||||
<GamesView />
|
||||
</Route>
|
||||
<Route path='/themes/'>
|
||||
<ThemesView {...{
|
||||
themeEngine,
|
||||
}} />
|
||||
</Route>
|
||||
|
||||
<Route path='/play/:encodedUrl'>
|
||||
{ playerView(themeEngine, false) }
|
||||
</Route>
|
||||
<Route path='/play/:encodedUrl/:theme'>
|
||||
{ playerView(themeEngine, false) }
|
||||
</Route>
|
||||
<Route path='/focus/:encodedUrl'>
|
||||
{ playerView(themeEngine, true) }
|
||||
</Route>
|
||||
<Route path='/focus/:encodedUrl/:theme'>
|
||||
{ playerView(themeEngine, true) }
|
||||
</Route>
|
||||
|
||||
<Route>
|
||||
<NotFoundView />
|
||||
</Route>
|
||||
</Switch>
|
||||
|
||||
</div>
|
||||
</Router>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,4 +1,6 @@
|
|||
export default function LocalFileSelector ({ theme, setLocation, buildLink }) {
|
||||
import { h } from 'preact'
|
||||
|
||||
export default function ({ theme, setLocation, buildLink }) {
|
||||
const fileInputHandler = ({ target }) => {
|
||||
const file = target.files[0]
|
||||
const url = `${URL.createObjectURL(file)}#${file.name}`
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
export default function TargetURLSelector ({ theme, setLocation, buildLink }) {
|
||||
import { h } from 'preact'
|
||||
|
||||
export default function ({ theme, setLocation, buildLink }) {
|
||||
const urlRE = /^(http|https):\/\/[^ "]+$/
|
||||
|
||||
const onKeyPress = ({ keyCode, target }) => {
|
||||
if (keyCode !== 13) return
|
||||
|
||||
const url = encodeURI(target.value)
|
||||
const url = target.value
|
||||
|
||||
if (urlRE.test(url)) {
|
||||
setLocation(buildLink({ url, theme }))
|
||||
|
|
|
|||
|
|
@ -1,24 +1,23 @@
|
|||
import { h } from 'preact'
|
||||
import { Link } from 'wouter-preact'
|
||||
|
||||
import {
|
||||
buildPlayLinkHref,
|
||||
} from '~/src/routing'
|
||||
buildPlayLinkHref
|
||||
} from '~/src/utils/utils.routing'
|
||||
|
||||
export default function GameEntry ({ name, ifdb, url }) {
|
||||
return (
|
||||
<div>
|
||||
<h4>{name}</h4>
|
||||
<a
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
href={ifdb}>
|
||||
IFDB page
|
||||
</a>
|
||||
<span> | </span>
|
||||
<Link
|
||||
href={buildPlayLinkHref({ url })}>
|
||||
Play
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default ({ name, ifdb, url }) => (
|
||||
<div>
|
||||
<h4>{name}</h4>
|
||||
<a
|
||||
target='_blank'
|
||||
rel='noopener'
|
||||
href={ifdb}>
|
||||
IFDB page
|
||||
</a>
|
||||
<span> | </span>
|
||||
<Link
|
||||
href={buildPlayLinkHref({ url })}>
|
||||
Play
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
108
src/components/Player/InputBox.jsx
Normal file
108
src/components/Player/InputBox.jsx
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import { h } from 'preact'
|
||||
import { useEffect, useRef, useState } from 'preact/hooks'
|
||||
|
||||
/* eslint-disable */
|
||||
const keyCodes = {
|
||||
KEY_BACKSPACE: 8,
|
||||
KEY_TAB: 9,
|
||||
KEY_RETURN: 13,
|
||||
KEY_ESC: 27,
|
||||
KEY_PAGEUP: 33,
|
||||
KEY_PAGEDOWN: 34,
|
||||
KEY_END: 35,
|
||||
KEY_HOME: 36,
|
||||
KEY_LEFT: 37,
|
||||
KEY_UP: 38,
|
||||
KEY_RIGHT: 39,
|
||||
KEY_DOWN: 40
|
||||
}
|
||||
|
||||
const keyNames = {
|
||||
[keyCodes.KEY_BACKSPACE]: 'delete',
|
||||
[keyCodes.KEY_TAB]: 'tab',
|
||||
[keyCodes.KEY_RETURN]: 'return',
|
||||
[keyCodes.KEY_ESC]: 'escape',
|
||||
[keyCodes.KEY_PAGEUP]: 'pageup',
|
||||
[keyCodes.KEY_PAGEDOWN]: 'pagedown',
|
||||
[keyCodes.KEY_END]: 'end',
|
||||
[keyCodes.KEY_HOME]: 'home',
|
||||
[keyCodes.KEY_LEFT]: 'left',
|
||||
[keyCodes.KEY_UP]: 'up',
|
||||
[keyCodes.KEY_RIGHT]: 'right',
|
||||
[keyCodes.KEY_DOWN]: 'down'
|
||||
}
|
||||
/* eslint-enable */
|
||||
|
||||
export default function ({ currentWindow, inputType, sendMessage }) {
|
||||
const [inputText, setInputText] = useState('')
|
||||
const [lastInput, setLastInput] = useState('')
|
||||
const inputEl = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
setInputText('')
|
||||
inputEl.current && inputEl.current.focus()
|
||||
}, [inputType])
|
||||
|
||||
const send = x => {
|
||||
sendMessage(x, currentWindow)
|
||||
setLastInput(x)
|
||||
setInputText('')
|
||||
}
|
||||
|
||||
const charHandler = event => {
|
||||
event.preventDefault()
|
||||
|
||||
const key =
|
||||
keyNames[event.keyCode] ||
|
||||
event.key
|
||||
|
||||
send(key)
|
||||
}
|
||||
|
||||
const lineHandler = ({ keyCode, target: { value } }) => {
|
||||
if (keyCode === keyCodes.KEY_RETURN) {
|
||||
send(value)
|
||||
}
|
||||
}
|
||||
|
||||
const lineArrowHandler = ({ keyCode }) => {
|
||||
if (keyCode === keyCodes.KEY_UP) {
|
||||
setInputText(lastInput)
|
||||
|
||||
setTimeout(_ => {
|
||||
const end = lastInput.length
|
||||
inputEl.current.setSelectionRange(end, end)
|
||||
}, 0)
|
||||
}
|
||||
if (keyCode === keyCodes.KEY_DOWN) {
|
||||
setInputText('')
|
||||
}
|
||||
}
|
||||
|
||||
const inputHandlers = {
|
||||
char: {
|
||||
placeholder: 'Press any key here',
|
||||
onKeyDown: charHandler
|
||||
},
|
||||
line: {
|
||||
placeholder: ' > ',
|
||||
onKeyDown: lineArrowHandler,
|
||||
onKeyPress: lineHandler
|
||||
}
|
||||
}
|
||||
|
||||
const enterFullscreen = _ =>
|
||||
document.documentElement.requestFullscreen()
|
||||
|
||||
return (
|
||||
<input {...inputHandlers[inputType]}
|
||||
className='inputBox'
|
||||
ref={inputEl}
|
||||
value={inputText}
|
||||
autofocus
|
||||
autocomplete='off'
|
||||
onDblClick={enterFullscreen}
|
||||
onInput={({ target: { value } }) => setInputText(value)}
|
||||
type='search' />
|
||||
)
|
||||
}
|
||||
|
|
@ -1,169 +0,0 @@
|
|||
import { useEffect, useRef, useState } from 'preact/hooks'
|
||||
|
||||
import MenuButton from './MenuButton/MenuButton'
|
||||
|
||||
import * as s from './InputBox.module.scss'
|
||||
|
||||
/* eslint-disable */
|
||||
const keyCodes = {
|
||||
KEY_BACKSPACE: 8,
|
||||
KEY_TAB: 9,
|
||||
KEY_RETURN: 13,
|
||||
KEY_ESC: 27,
|
||||
KEY_PAGEUP: 33,
|
||||
KEY_PAGEDOWN: 34,
|
||||
KEY_END: 35,
|
||||
KEY_HOME: 36,
|
||||
KEY_LEFT: 37,
|
||||
KEY_UP: 38,
|
||||
KEY_RIGHT: 39,
|
||||
KEY_DOWN: 40
|
||||
}
|
||||
|
||||
const keyNames = {
|
||||
[keyCodes.KEY_BACKSPACE]: 'delete',
|
||||
[keyCodes.KEY_TAB]: 'tab',
|
||||
[keyCodes.KEY_RETURN]: 'return',
|
||||
[keyCodes.KEY_ESC]: 'escape',
|
||||
[keyCodes.KEY_PAGEUP]: 'pageup',
|
||||
[keyCodes.KEY_PAGEDOWN]: 'pagedown',
|
||||
[keyCodes.KEY_END]: 'end',
|
||||
[keyCodes.KEY_HOME]: 'home',
|
||||
[keyCodes.KEY_LEFT]: 'left',
|
||||
[keyCodes.KEY_UP]: 'up',
|
||||
[keyCodes.KEY_RIGHT]: 'right',
|
||||
[keyCodes.KEY_DOWN]: 'down'
|
||||
}
|
||||
/* eslint-enable */
|
||||
|
||||
const hasModifier = (event) => {
|
||||
const modifiers = [
|
||||
event.altKey,
|
||||
event.ctrlKey,
|
||||
event.metaKey,
|
||||
event.shiftKey,
|
||||
]
|
||||
|
||||
return modifiers.some((modifier) => modifier === true)
|
||||
}
|
||||
|
||||
export default function InputBox ({
|
||||
inputType,
|
||||
windows,
|
||||
currentWindowId,
|
||||
sendMessage,
|
||||
onFullscreenRequest,
|
||||
setMenuOpen,
|
||||
}) {
|
||||
const [targetWindow, setTargetWindow] = useState(null)
|
||||
const [inputText, setInputText] = useState('')
|
||||
const [lastInput, setLastInput] = useState('')
|
||||
const inputEl = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
let setFocus = () => {
|
||||
inputEl.current && inputEl.current.focus()
|
||||
}
|
||||
|
||||
setInputText('')
|
||||
setFocus()
|
||||
|
||||
document.addEventListener('fullscreenchange', setFocus)
|
||||
return () => document.removeEventListener('fullscreenchange', setFocus)
|
||||
}, [inputType])
|
||||
|
||||
useEffect(() => {
|
||||
setTargetWindow(
|
||||
windows
|
||||
.find(({ id }) =>
|
||||
id === currentWindowId))
|
||||
}, [currentWindowId, windows])
|
||||
|
||||
const send = (message) => {
|
||||
sendMessage(
|
||||
message,
|
||||
inputType,
|
||||
targetWindow)
|
||||
setLastInput(message)
|
||||
setInputText('')
|
||||
}
|
||||
|
||||
const charHandler = (event) =>
|
||||
(event.keyCode === 229
|
||||
? charHandlerMobile
|
||||
: charHandlerDefault)(event)
|
||||
|
||||
const charHandlerDefault = (event) => {
|
||||
if (hasModifier(event)) { return undefined }
|
||||
|
||||
event.preventDefault()
|
||||
|
||||
const key =
|
||||
keyNames[event.keyCode] ||
|
||||
event.key
|
||||
|
||||
send(key)
|
||||
}
|
||||
|
||||
const charHandlerMobile = (event) =>
|
||||
setTimeout(() => {
|
||||
send(event.target.value.slice(-1).toUpperCase())
|
||||
setInputText('')
|
||||
})
|
||||
|
||||
const lineHandler = ({ keyCode, target: { value } }) => {
|
||||
if (keyCode === keyCodes.KEY_RETURN) {
|
||||
send(value)
|
||||
}
|
||||
}
|
||||
|
||||
const lineArrowHandler = ({ keyCode }) => {
|
||||
if (keyCode === keyCodes.KEY_UP) {
|
||||
setInputText(lastInput)
|
||||
|
||||
setTimeout(() => {
|
||||
const end = lastInput.length
|
||||
inputEl.current.setSelectionRange(end, end)
|
||||
}, 0)
|
||||
}
|
||||
if (keyCode === keyCodes.KEY_DOWN) {
|
||||
setInputText('')
|
||||
}
|
||||
}
|
||||
|
||||
const inputHandlers = {
|
||||
char: {
|
||||
maxlength: '1',
|
||||
placeholder: 'Press any key here',
|
||||
onKeyDown: charHandler,
|
||||
},
|
||||
line: {
|
||||
placeholder: ' > ',
|
||||
onKeyDown: lineArrowHandler,
|
||||
onKeyPress: lineHandler,
|
||||
},
|
||||
finished: {
|
||||
placeholder: 'The program has finished',
|
||||
disabled: true,
|
||||
},
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={s.inputControls}>
|
||||
<input {...inputHandlers[inputType]}
|
||||
className={s.inputBox}
|
||||
ref={inputEl}
|
||||
value={inputText}
|
||||
autofocus
|
||||
autocomplete='off'
|
||||
spellCheck='false'
|
||||
autocapitalize='off'
|
||||
autocorrect='off'
|
||||
onDblClick={onFullscreenRequest}
|
||||
onInput={({ target: { value } }) => setInputText(value)}
|
||||
type='search' />
|
||||
<MenuButton
|
||||
onClick={() => setMenuOpen(true)} />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
.inputControls {
|
||||
position: relative;
|
||||
margin-top: var(--input-box-margin);
|
||||
|
||||
.inputBox {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
outline: 0;
|
||||
|
||||
background-color: var(--bg-color);
|
||||
border: var(--border-width) solid var(--main-color);
|
||||
border-top: var(--separator-width) solid var(--main-color);
|
||||
padding: var(--inner-padding);
|
||||
padding-right: calc(4 * var(--inner-padding));
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--main-color);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&:focus::placeholder {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
&::-webkit-search-cancel-button {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
import * as s from './MenuButton.module.scss'
|
||||
|
||||
export default function MenuButton ({ onClick }) {
|
||||
return (
|
||||
<button
|
||||
aria-label='Menu'
|
||||
className={s.menuButton}
|
||||
onClick={onClick}
|
||||
>
|
||||
<svg
|
||||
class={s.menuIcon}
|
||||
viewBox='0 0 28 32'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<rect x='12' y='4' width='4' height='4' />
|
||||
<rect x='12' y='14' width='4' height='4' />
|
||||
<rect x='12' y='24' width='4' height='4' />
|
||||
</svg>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
.menuButton {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: none;
|
||||
border: none;
|
||||
outline-offset: -8px;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
|
||||
@media (max-width: 800px) {
|
||||
padding: 0 calc(0.5 * var(--inner-padding));
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline-offset: -4px;
|
||||
}
|
||||
|
||||
.menuIcon {
|
||||
height: 32px;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 2px;
|
||||
|
||||
@media (max-width: 800px) {
|
||||
transform: scaleX(2) scaleY(0.5);
|
||||
fill: currentColor;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,79 +0,0 @@
|
|||
import { useEffect, useRef } from 'preact/hooks'
|
||||
import { Link } from 'wouter-preact'
|
||||
|
||||
import ThemeSelector from
|
||||
'~/src/components/ThemeSelector/ThemeSelector'
|
||||
|
||||
import * as s from './MenuOverlay.module.scss'
|
||||
|
||||
export default function MenuOverlay ({
|
||||
themeEngine, onFullscreenRequest, menuOpen, setMenuOpen,
|
||||
}) {
|
||||
const dialog = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
const dialogOpen = dialog.current.open
|
||||
|
||||
if (menuOpen && !dialogOpen) {
|
||||
dialog.current.showModal()
|
||||
}
|
||||
|
||||
if (!menuOpen && dialogOpen) {
|
||||
dialog.current.close()
|
||||
}
|
||||
}, [menuOpen])
|
||||
|
||||
useEffect(() => {
|
||||
const currentDialog = dialog.current
|
||||
const closeHandler = () => {
|
||||
setMenuOpen(false)
|
||||
}
|
||||
|
||||
currentDialog.addEventListener('close', closeHandler)
|
||||
return () => currentDialog.removeEventListener('close', closeHandler)
|
||||
}, [dialog, setMenuOpen])
|
||||
|
||||
|
||||
return (
|
||||
<dialog ref={dialog} className={s.menu}>
|
||||
<section>
|
||||
<div>
|
||||
<button
|
||||
tabIndex={0}
|
||||
onClick={() => dialog.current.close()}
|
||||
>
|
||||
Close this menu
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={s.appearance}>
|
||||
<button
|
||||
onClick={() => {
|
||||
dialog.current.close()
|
||||
onFullscreenRequest()
|
||||
}}
|
||||
>
|
||||
Full screen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => themeEngine.setRandomTheme()}
|
||||
>
|
||||
Set a random theme
|
||||
</button>
|
||||
<label>
|
||||
Current theme:
|
||||
<ThemeSelector {...{
|
||||
themeEngine,
|
||||
}} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className={s.navigation}>
|
||||
<Link href="/" tabIndex={0}>
|
||||
ElseIfPlayer
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</dialog>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
.menu {
|
||||
width: 100%;
|
||||
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
text-align: center;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
|
||||
background-color: var(--bg-color);
|
||||
border-color: var(--main-color);
|
||||
color: var(--main-color);
|
||||
|
||||
&::backdrop {
|
||||
background: none;
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
& > section {
|
||||
margin: 32px auto 40px;
|
||||
gap: 32px;
|
||||
max-width: 270px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.navigation {
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.appearance {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
select,
|
||||
button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,79 +0,0 @@
|
|||
import { useEffect, useState } from 'preact/hooks'
|
||||
|
||||
import TextMessage from '../TextMessage/TextMessage'
|
||||
|
||||
import * as s from '../../Player.module.scss'
|
||||
|
||||
export default function GridBuffer ({ inbox, currentWindow }) {
|
||||
const [prevMessages, setPrevMessages] = useState([])
|
||||
const [messages, setMessages] = useState([])
|
||||
|
||||
useEffect(() => {
|
||||
const currentInboxObj =
|
||||
inbox.find(({ id }) =>
|
||||
id === currentWindow.id)
|
||||
|
||||
const currentInbox = currentInboxObj?.lines ?? []
|
||||
|
||||
const newOrPrev = (cur, prev) => (i) => {
|
||||
const byId = (list, i) =>
|
||||
list.find(({ line }) => line === i)
|
||||
|
||||
return byId(cur, i) || byId(prev, i)
|
||||
}
|
||||
|
||||
const rawMessages =
|
||||
Array(currentWindow.gridheight)
|
||||
.fill(null)
|
||||
.map((_, i) => i)
|
||||
.map(newOrPrev(currentInbox, prevMessages))
|
||||
|
||||
/* */
|
||||
|
||||
const shouldUpdatePrev = (rawMessages, prevMessages) => {
|
||||
const serialize = JSON.stringify
|
||||
return serialize(rawMessages) !== serialize(prevMessages)
|
||||
}
|
||||
|
||||
if (shouldUpdatePrev(rawMessages, prevMessages)) {
|
||||
setPrevMessages(rawMessages)
|
||||
}
|
||||
|
||||
/* */
|
||||
|
||||
|
||||
const rawMessagesContent =
|
||||
rawMessages
|
||||
.map((x) => x.content)
|
||||
.flat()
|
||||
.map((message) => ({
|
||||
...message,
|
||||
text: message.text.trim(),
|
||||
}))
|
||||
|
||||
const isEmpty =
|
||||
rawMessagesContent
|
||||
.map(({ text }) => text.length)
|
||||
.every((l) => l === 0)
|
||||
|
||||
const getGridStyle = ({ style }) => {
|
||||
if (['alert', 'normal'].includes(style)) return 'grid'
|
||||
return style || 'grid'
|
||||
}
|
||||
|
||||
const messages =
|
||||
rawMessagesContent
|
||||
.map((message) => ({
|
||||
style: getGridStyle(message),
|
||||
text: message.text.replace(' ', ' / '),
|
||||
}))
|
||||
|
||||
setMessages(isEmpty ? [] : messages)
|
||||
}, [inbox, currentWindow, prevMessages])
|
||||
|
||||
return (
|
||||
<section className={[s.buffer, s.gridBuffer].join(' ')}>
|
||||
{messages.map(TextMessage)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,97 +0,0 @@
|
|||
import { useEffect, useRef, useState } from 'preact/hooks'
|
||||
|
||||
import TextMessage from '../TextMessage/TextMessage'
|
||||
|
||||
import * as s from '../../Player.module.scss'
|
||||
|
||||
const eol = { style: 'endOfLine' }
|
||||
const scrollTarget = { style: 'scrollTarget' }
|
||||
|
||||
const isFakeStatus = (w) =>
|
||||
w.height < 5
|
||||
|
||||
const trimInputPrompt = (messages) =>
|
||||
messages.length < 1
|
||||
? messages
|
||||
: messages.slice(-1)[0].text === '>'
|
||||
? messages.slice(0, messages.length - 1)
|
||||
: messages
|
||||
|
||||
const parseInbox = (inbox, currentWindow) => {
|
||||
const currentInbox =
|
||||
inbox.find(({ id }) =>
|
||||
id === currentWindow.id)
|
||||
|
||||
if (!currentInbox) {
|
||||
return {
|
||||
clear: false,
|
||||
incoming: [scrollTarget],
|
||||
}
|
||||
}
|
||||
|
||||
const { text: inboxMessagesRaw } =
|
||||
currentInbox
|
||||
|
||||
const incoming =
|
||||
inboxMessagesRaw
|
||||
/* Normalize. */
|
||||
.map(({ content }) =>
|
||||
content
|
||||
? [...trimInputPrompt(content), eol]
|
||||
: [eol])
|
||||
/* Flatten. */
|
||||
.reduce((acc, x) =>
|
||||
acc.concat(x), [scrollTarget])
|
||||
|
||||
return {
|
||||
incoming,
|
||||
clear: isFakeStatus(currentWindow)
|
||||
? true
|
||||
: currentInbox.clear,
|
||||
}
|
||||
}
|
||||
|
||||
export default function TextBuffer ({ inbox, currentWindow }) {
|
||||
const [messages, setMessages] = useState([])
|
||||
const textBufferEl = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
const { incoming, clear } =
|
||||
parseInbox(inbox, currentWindow)
|
||||
|
||||
setMessages((messages) => clear
|
||||
? incoming
|
||||
: messages.concat(incoming))
|
||||
|
||||
setTimeout(() => {
|
||||
const scrollTargets =
|
||||
textBufferEl.current.querySelectorAll(`.${scrollTarget.style}`)
|
||||
const freshScrollTarget =
|
||||
scrollTargets[scrollTargets.length - 1]
|
||||
|
||||
freshScrollTarget
|
||||
? freshScrollTarget.scrollIntoView()
|
||||
: textBufferEl.current.scrollTo({
|
||||
top: textBufferEl.current.scrollHeight,
|
||||
behavior: 'smooth',
|
||||
})
|
||||
}, 0)
|
||||
}, [currentWindow, inbox])
|
||||
|
||||
const classes = () => [
|
||||
s.buffer,
|
||||
isFakeStatus(currentWindow)
|
||||
? s.gridBuffer
|
||||
: s.textBuffer,
|
||||
].join(' ')
|
||||
|
||||
return (
|
||||
<section
|
||||
tabindex='0'
|
||||
ref={textBufferEl}
|
||||
className={classes()}
|
||||
>
|
||||
{messages.map(TextMessage)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
import * as s from './TextMessage.module.scss'
|
||||
|
||||
export default function TextMessage ({ style, text }) {
|
||||
const defaultContent = (
|
||||
<span className={[s.message, s[style]].join(' ')}>
|
||||
{text}
|
||||
</span>)
|
||||
|
||||
return ({
|
||||
grid:
|
||||
(text?.length > 0 ? <div>{text}</div> : <br />),
|
||||
input:
|
||||
(<span className={[s.message, s.input].join(' ')}>> {text}</span>),
|
||||
subheader:
|
||||
(<strong className={[s.message, s.subheader].join(' ')}>{text}</strong>),
|
||||
emphasized:
|
||||
(<em className={[s.message, s.emphasized].join(' ')}>{text}</em>),
|
||||
scrollTarget:
|
||||
(<div className={[s.scrollTarget, style].join(' ')}></div>),
|
||||
endOfLine:
|
||||
(<br />),
|
||||
})[style] || defaultContent
|
||||
}
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
.message {
|
||||
&.input {
|
||||
color: var(--input-color);
|
||||
}
|
||||
|
||||
&.emphasized,
|
||||
&.subheader {
|
||||
color: var(--accent-color);
|
||||
}
|
||||
}
|
||||
|
||||
.scrollTarget {
|
||||
scroll-margin-block-start: var(--inner-padding);
|
||||
}
|
||||
|
|
@ -1,51 +1,71 @@
|
|||
import { h } from 'preact'
|
||||
import { useState, useEffect } from 'preact/hooks'
|
||||
import {
|
||||
compressToUTF16 as encode,
|
||||
decompressFromUTF16 as decode
|
||||
} from 'lz-string'
|
||||
|
||||
import CheapGlkOte from 'cheap-glkote'
|
||||
|
||||
import TextBuffer from './OutputBox/TextBuffer/TextBuffer'
|
||||
import GridBuffer from './OutputBox/GridBuffer/GridBuffer'
|
||||
import TextBuffer from './TextBuffer'
|
||||
import InputBox from './InputBox'
|
||||
import Status from './Status'
|
||||
|
||||
import InputBox from './InputBox/InputBox'
|
||||
import Status from './Status/Status'
|
||||
|
||||
import {
|
||||
Handlers,
|
||||
unhandledRejectionHandler,
|
||||
} from './common/playerHandlers'
|
||||
|
||||
import * as s from './Player.module.scss'
|
||||
import './player.css'
|
||||
|
||||
const INITIAL_STATUS = {
|
||||
stage: 'loading',
|
||||
details: ['Preparing'],
|
||||
details: ['Preparing']
|
||||
}
|
||||
|
||||
const runMachine = ({ engine: Engine, wasmBinary, storyfile, handlers }) => {
|
||||
const { Dialog, GlkOte, send } = CheapGlkOte(handlers)
|
||||
const instance = new Engine()
|
||||
const runMachine = ({ engine: Engine, file, handlers }) => {
|
||||
const vm = new Engine()
|
||||
const { glkInterface, sendFn } = CheapGlkOte(handlers)
|
||||
|
||||
instance.init(storyfile, {
|
||||
Dialog,
|
||||
GlkOte,
|
||||
Glk: {},
|
||||
wasmBinary,
|
||||
arguments: ['storyfile'],
|
||||
})
|
||||
instance.start()
|
||||
vm.prepare(file, glkInterface)
|
||||
vm.start()
|
||||
|
||||
return { send, instance }
|
||||
return { sendFn, instance: vm }
|
||||
}
|
||||
|
||||
export default function Player ({
|
||||
vmParts: { storyfile, engine, wasmBinary },
|
||||
onFullscreenRequest,
|
||||
setMenuOpen,
|
||||
singleWindow,
|
||||
}) {
|
||||
const Handlers = ({
|
||||
setStatus,
|
||||
setCurrentWindow,
|
||||
setInputType,
|
||||
setInbox
|
||||
}) => ({
|
||||
onInit: _ => setStatus({ stage: 'ready' }),
|
||||
/* */
|
||||
onUpdateWindows: windows => {
|
||||
setCurrentWindow(windows
|
||||
.filter(x => x.type === 'buffer')
|
||||
.slice(-1)[0])
|
||||
},
|
||||
onUpdateInputs: setInputType,
|
||||
onUpdateContent: setInbox,
|
||||
onDisable: _ => setInputType(null),
|
||||
/* */
|
||||
onFileNameRequest: (tosave, usage, _, setFileName) => {
|
||||
setFileName({
|
||||
usage,
|
||||
filename: prompt('Enter the filename')
|
||||
})
|
||||
},
|
||||
onFileRead: ({ filename }) => {
|
||||
const content = localStorage.getItem(`fake-fs/${filename}`)
|
||||
return decode(content)
|
||||
},
|
||||
onFileWrite: ({ filename }, content) => {
|
||||
localStorage.setItem(`fake-fs/${filename}`, encode(content))
|
||||
},
|
||||
/* */
|
||||
onExit: _ => setInputType(null)
|
||||
})
|
||||
|
||||
export default function ({ vmParts: { file, engine } }) {
|
||||
const [status, setStatus] = useState(INITIAL_STATUS)
|
||||
|
||||
const [windows, setWindows] = useState([])
|
||||
const [currentWindowId, setCurrentWindowId] = useState(null)
|
||||
const [currentWindow, setCurrentWindow] = useState(null)
|
||||
const [inputType, setInputType] = useState(null)
|
||||
const [inbox, setInbox] = useState([])
|
||||
|
||||
|
|
@ -53,75 +73,39 @@ export default function Player ({
|
|||
const [sendMessage, setSendMessage] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
const handlers = Handlers({
|
||||
setStatus,
|
||||
setWindows,
|
||||
setCurrentWindowId,
|
||||
setInputType,
|
||||
setInbox,
|
||||
const vm = runMachine({
|
||||
engine,
|
||||
file,
|
||||
handlers: Handlers({
|
||||
setStatus,
|
||||
setCurrentWindow,
|
||||
setInputType,
|
||||
setInbox
|
||||
})
|
||||
})
|
||||
|
||||
setVm(runMachine({
|
||||
engine,
|
||||
wasmBinary,
|
||||
storyfile,
|
||||
handlers,
|
||||
}))
|
||||
|
||||
const rejectionHandler =
|
||||
unhandledRejectionHandler(handlers.onExit)
|
||||
|
||||
window.addEventListener('unhandledrejection', rejectionHandler)
|
||||
|
||||
return () => {
|
||||
setVm(null)
|
||||
window.removeEventListener('unhandledrejection', rejectionHandler)
|
||||
}
|
||||
}, [storyfile, engine, wasmBinary])
|
||||
setVm(vm)
|
||||
}, [file, engine])
|
||||
|
||||
useEffect(() => {
|
||||
setSendMessage(() => vm
|
||||
? vm.send
|
||||
setSendMessage(_ => vm
|
||||
? vm.sendFn
|
||||
: null)
|
||||
|
||||
return () => setSendMessage(null)
|
||||
}, [vm])
|
||||
|
||||
const textWindow = (inbox) => (currentWindow) => {
|
||||
const props = {
|
||||
inbox,
|
||||
currentWindow,
|
||||
}
|
||||
|
||||
return ({
|
||||
buffer: <TextBuffer {...props} />,
|
||||
grid: <GridBuffer {...props} />,
|
||||
})[currentWindow.type]
|
||||
}
|
||||
|
||||
const byTop = (a, b) =>
|
||||
a.top - b.top
|
||||
|
||||
return status.stage !== 'ready'
|
||||
? (<Status {...status} />)
|
||||
: (<section className={s.elseifplayer}>
|
||||
<section className={s.output}>
|
||||
{
|
||||
windows
|
||||
.sort(byTop)
|
||||
.filter(singleWindow
|
||||
? ({ id }) => id === currentWindowId
|
||||
: () => true)
|
||||
.map(textWindow(inbox))
|
||||
}
|
||||
: (
|
||||
<section className='ifplayer'>
|
||||
<TextBuffer {...{
|
||||
inbox,
|
||||
currentWindow
|
||||
}} />
|
||||
<InputBox {...{
|
||||
currentWindow,
|
||||
inputType,
|
||||
sendMessage
|
||||
}} />
|
||||
</section>
|
||||
<InputBox {...{
|
||||
inputType,
|
||||
windows,
|
||||
currentWindowId,
|
||||
sendMessage,
|
||||
onFullscreenRequest,
|
||||
setMenuOpen,
|
||||
}} />
|
||||
</section>)
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,49 +0,0 @@
|
|||
.elseifplayer {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
|
||||
background-color: var(--bg-color);
|
||||
color: var(--main-color);
|
||||
padding: var(--outer-padding);
|
||||
|
||||
.output {
|
||||
display: flex;
|
||||
flex-grow: 2;
|
||||
flex-direction: column;
|
||||
overflow-y: hidden;
|
||||
overflow-wrap: break-word;
|
||||
|
||||
border: var(--border-width) solid var(--main-color);
|
||||
|
||||
.buffer {
|
||||
overflow-y: scroll;
|
||||
box-sizing: border-box;
|
||||
|
||||
padding: var(--inner-padding);
|
||||
|
||||
&:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
& > br:first-child,
|
||||
& > br:last-child,
|
||||
& > br + br + br {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&.gridBuffer {
|
||||
flex-shrink: 0;
|
||||
max-height: 100%;
|
||||
border-bottom: var(--separator-width) solid var(--main-color);
|
||||
}
|
||||
|
||||
&.textBuffer {
|
||||
flex: 2 1;
|
||||
outline: none;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
31
src/components/Player/Status.jsx
Normal file
31
src/components/Player/Status.jsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { h } from 'preact'
|
||||
import { Link } from 'wouter-preact'
|
||||
|
||||
const fail = details => (
|
||||
<div class='status fail'>
|
||||
<h1>
|
||||
Error
|
||||
</h1>
|
||||
{details.map(x => (<p>{x}</p>))}
|
||||
<hr />
|
||||
<Link href='/'>
|
||||
Home
|
||||
</Link>
|
||||
|
|
||||
<a
|
||||
target='_blank'
|
||||
rel='noopener'
|
||||
href='https://github.com/He4eT/ifplayer/issues'>
|
||||
Report bug
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
|
||||
const loading = details => (
|
||||
<div class='status loading'>
|
||||
{details.map(x => (<div>{x}</div>))}
|
||||
</div>
|
||||
)
|
||||
|
||||
export default ({ stage, details }) =>
|
||||
({ fail, loading })[stage](details)
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
import { Link } from 'wouter-preact'
|
||||
|
||||
import * as s from './Status.module.scss'
|
||||
|
||||
const fail = (details) => (
|
||||
<div className={[s.status].join(' ')}>
|
||||
<h1>
|
||||
Error
|
||||
</h1>
|
||||
{details.map((x) => (<p key={x}>{x}</p>))}
|
||||
<hr />
|
||||
<Link href='/'>
|
||||
Home
|
||||
</Link>
|
||||
|
|
||||
<a
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
href='https://github.com/He4eT/elseifplayer/issues'
|
||||
>
|
||||
Report bug
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
|
||||
const loading = (details) => (
|
||||
<div className={[s.status, s.loading].join(' ')}>
|
||||
{details.map((x) => (<div key={x}>{x}</div>))}
|
||||
</div>
|
||||
)
|
||||
|
||||
export default ({ stage, details }) =>
|
||||
({ fail, loading })[stage](details)
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
@keyframes dots0123 {
|
||||
0% { content: ''; }
|
||||
33% { content: '.'; }
|
||||
66% { content: '..'; }
|
||||
100% { content: '...'; }
|
||||
}
|
||||
|
||||
.status {
|
||||
word-break: break-word;
|
||||
padding-block: var(--inner-padding);
|
||||
|
||||
@media (max-width: 800px) {
|
||||
padding: var(--inner-padding);
|
||||
}
|
||||
|
||||
&.loading > div:after {
|
||||
animation: dots0123 1s infinite;
|
||||
content: '';
|
||||
}
|
||||
}
|
||||
|
||||
76
src/components/Player/TextBuffer.jsx
Normal file
76
src/components/Player/TextBuffer.jsx
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import { h } from 'preact'
|
||||
import { useEffect, useRef, useState } from 'preact/hooks'
|
||||
|
||||
import TextMessage from './TextMessage'
|
||||
|
||||
const trimInputPrompt = messages =>
|
||||
messages.length < 1
|
||||
? messages
|
||||
: messages.slice(-1)[0].text === '>'
|
||||
? messages.slice(0, messages.length - 1)
|
||||
: messages
|
||||
|
||||
const parseInbox = (inbox, currentWindow) => {
|
||||
const currentInbox =
|
||||
inbox.find(({ id }) =>
|
||||
id === currentWindow.id)
|
||||
|
||||
if (!currentInbox) {
|
||||
return {
|
||||
clear: false,
|
||||
incoming: []
|
||||
}
|
||||
}
|
||||
|
||||
const { clear, text: inboxMessagesRaw } =
|
||||
currentInbox
|
||||
|
||||
const eol = { style: 'endOfLine' }
|
||||
|
||||
const incoming =
|
||||
inboxMessagesRaw
|
||||
/* Normalize. */
|
||||
.map(({ content }) =>
|
||||
content
|
||||
? [...trimInputPrompt(content), eol]
|
||||
: [eol])
|
||||
/* Flatten. */
|
||||
.reduce((acc, x) =>
|
||||
acc.concat(x), [])
|
||||
|
||||
return { clear, incoming }
|
||||
}
|
||||
|
||||
export default function ({ inbox, currentWindow }) {
|
||||
const [messages, setMessages] = useState([])
|
||||
const textBufferEl = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
const { incoming, clear } =
|
||||
parseInbox(inbox, currentWindow)
|
||||
|
||||
setMessages(clear
|
||||
? incoming
|
||||
: messages.concat(incoming))
|
||||
|
||||
setTimeout(() => {
|
||||
const inputs =
|
||||
textBufferEl.current.querySelectorAll('.message.input')
|
||||
const lastInput =
|
||||
inputs[inputs.length - 1]
|
||||
|
||||
textBufferEl.current.scrollTop =
|
||||
lastInput
|
||||
? lastInput.offsetTop
|
||||
: textBufferEl.current.scrollHeight * 2
|
||||
}, 0)
|
||||
}, [inbox])
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={textBufferEl}
|
||||
className='textBuffer'>
|
||||
{messages.map(TextMessage)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
15
src/components/Player/TextMessage.jsx
Normal file
15
src/components/Player/TextMessage.jsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { h } from 'preact'
|
||||
|
||||
export default function ({ style, text }) {
|
||||
const defaultContent = (
|
||||
<span class={['message', style].join(' ')}>
|
||||
{text}
|
||||
</span>)
|
||||
|
||||
return ({
|
||||
input: (<span class='message input'>> {text}</span>),
|
||||
subheader: (<strong>{text}</strong>),
|
||||
emphasized: (<em>{text}</em>),
|
||||
endOfLine: (<br />)
|
||||
})[style] || defaultContent
|
||||
}
|
||||
|
|
@ -1,64 +1,41 @@
|
|||
import { h } from 'preact'
|
||||
import { useState, useEffect } from 'preact/hooks'
|
||||
|
||||
import { engineByFilename } from './common/engines'
|
||||
|
||||
import Player from './Player'
|
||||
import Status from './Status/Status'
|
||||
import Status from './Status'
|
||||
|
||||
const INITIAL_STATUS = {
|
||||
stage: 'loading',
|
||||
details: ['Loading'],
|
||||
details: ['Loading']
|
||||
}
|
||||
|
||||
const prepareVM = ({ url, setStatus, setParts }) => {
|
||||
const st = (stage, details) => (args) => {
|
||||
const st = (stage, details) => args => {
|
||||
setStatus({ stage, details: [details] })
|
||||
return args
|
||||
}
|
||||
|
||||
const cleanUrl = (url) =>
|
||||
url.startsWith('blob:')
|
||||
? url.replace(/#(.*)$/g, '')
|
||||
: url
|
||||
|
||||
const fetchWasm = (wasmBinaryName) =>
|
||||
fetch(wasmBinaryName)
|
||||
.then((response) => response.arrayBuffer())
|
||||
|
||||
const checkResponse = (response) => {
|
||||
if (response.ok) return response
|
||||
throw new Error(response.statusText)
|
||||
}
|
||||
|
||||
return Promise.resolve(url)
|
||||
return Promise.resolve()
|
||||
.then(st('loading', 'Downloading file'))
|
||||
.then(cleanUrl)
|
||||
.then(fetch)
|
||||
.then(checkResponse)
|
||||
.then(_ => fetch(url))
|
||||
.then(st('loading', 'Processing file'))
|
||||
.then((response) => response.arrayBuffer())
|
||||
.then((arrayBuffer) => new Uint8Array(arrayBuffer))
|
||||
.then(response => response.arrayBuffer())
|
||||
.then(arrayBuffer => new Uint8Array(arrayBuffer))
|
||||
.then(st('loading', 'Downloading engine'))
|
||||
.then((storyfile) => {
|
||||
let parts = engineByFilename(url)
|
||||
return [storyfile, parts.engine, parts.wasmBinaryName]
|
||||
})
|
||||
.then(([storyfile, engine, wasmBinaryName]) => Promise.all([
|
||||
storyfile, engine, fetchWasm(wasmBinaryName),
|
||||
]))
|
||||
.then(([storyfile, engine, wasmBinary]) => setParts({
|
||||
storyfile, engine, wasmBinary,
|
||||
.then(file => setParts({
|
||||
file,
|
||||
engine: engineByFilename(url)
|
||||
}))
|
||||
.then(st('loading', 'Running'))
|
||||
.catch((e) => {
|
||||
.catch(e => {
|
||||
console.error(e)
|
||||
setStatus({ stage: 'fail', details: [e.message, url] })
|
||||
})
|
||||
}
|
||||
|
||||
export default function UrlPlayer ({
|
||||
url, singleWindow, onFullscreenRequest, setMenuOpen,
|
||||
}) {
|
||||
export default function ({ url }) {
|
||||
const [status, setStatus] = useState(INITIAL_STATUS)
|
||||
const [vmParts, setParts] = useState(null)
|
||||
|
||||
|
|
@ -67,16 +44,9 @@ export default function UrlPlayer ({
|
|||
setParts(null)
|
||||
|
||||
prepareVM({ url, setStatus, setParts })
|
||||
|
||||
return () => setParts(null)
|
||||
}, [url])
|
||||
|
||||
return vmParts
|
||||
? (<Player {...{
|
||||
vmParts,
|
||||
onFullscreenRequest,
|
||||
setMenuOpen,
|
||||
singleWindow,
|
||||
}} />)
|
||||
? (<Player vmParts={vmParts} />)
|
||||
: (<Status {...status} />)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,46 +2,37 @@ import bocfel from 'emglken/src/bocfel.js'
|
|||
import git from 'emglken/src/git.js'
|
||||
import hugo from 'emglken/src/hugo.js'
|
||||
import tads from 'emglken/src/tads.js'
|
||||
import scare from 'emglken/src/scare.js'
|
||||
|
||||
const formats = [
|
||||
{
|
||||
id: 'bocfel',
|
||||
extensions: /z([3458]|blorb)$/,
|
||||
engine: bocfel,
|
||||
engine: bocfel
|
||||
},
|
||||
{
|
||||
id: 'git',
|
||||
extensions: /(gblorb|ulx)$/,
|
||||
engine: git,
|
||||
engine: git
|
||||
},
|
||||
{
|
||||
id: 'hugo',
|
||||
extensions: /hex$/,
|
||||
engine: hugo,
|
||||
},
|
||||
{
|
||||
id: 'scare',
|
||||
extensions: /taf$/,
|
||||
engine: scare,
|
||||
engine: hugo
|
||||
},
|
||||
{
|
||||
id: 'tads',
|
||||
extensions: /(gam|t3)$/,
|
||||
engine: tads,
|
||||
},
|
||||
engine: tads
|
||||
}
|
||||
]
|
||||
|
||||
export const engineByFilename = (filename) => {
|
||||
const format = formats.find((x) =>
|
||||
export const engineByFilename = filename => {
|
||||
const format = formats.find(x =>
|
||||
x.extensions.test(filename))
|
||||
|
||||
if (format) {
|
||||
return {
|
||||
...format,
|
||||
/* @see staticFiles in package.json */
|
||||
wasmBinaryName: `emglken/${format.id}-core.wasm`,
|
||||
}
|
||||
return format.engine
|
||||
} else {
|
||||
throw new Error('Unsupported file type')
|
||||
}
|
||||
throw new Error('Unsupported file type')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,57 +0,0 @@
|
|||
import { encode, decode } from 'base32768'
|
||||
|
||||
export const Handlers = ({
|
||||
setStatus,
|
||||
setWindows,
|
||||
setCurrentWindowId,
|
||||
setInputType,
|
||||
setInbox,
|
||||
}) => ({
|
||||
onInit: () => {
|
||||
setStatus({ stage: 'ready' })
|
||||
},
|
||||
/* */
|
||||
onUpdateWindows: (windows) => {
|
||||
setWindows(windows)
|
||||
},
|
||||
onUpdateInputs: (data) => {
|
||||
if (data.length === 0) return null
|
||||
|
||||
const { type, id } = data[0]
|
||||
setCurrentWindowId(id)
|
||||
setInputType(type)
|
||||
},
|
||||
onUpdateContent: (inbox) => {
|
||||
setInbox(inbox)
|
||||
},
|
||||
onDisable: () => {
|
||||
setInputType(null)
|
||||
},
|
||||
/* */
|
||||
onFileNameRequest: (_tosave, usage, _gameId, setFileName) => {
|
||||
setFileName({
|
||||
usage,
|
||||
filename: prompt('Enter the filename'),
|
||||
})
|
||||
},
|
||||
onFileRead: ({ filename }) => {
|
||||
const content = localStorage.getItem(`fake-fs/${filename}`)
|
||||
return decode(content)
|
||||
},
|
||||
onFileWrite: ({ filename }, content) => {
|
||||
localStorage.setItem(`fake-fs/${filename}`, encode(content))
|
||||
},
|
||||
/* */
|
||||
onExit: () => {
|
||||
setInputType('finished')
|
||||
},
|
||||
})
|
||||
|
||||
export const unhandledRejectionHandler = (onExit) => (event) => {
|
||||
if (event.reason.name === 'ExitStatus' || event.reason.message === 'Program terminated with exit(0)') {
|
||||
onExit()
|
||||
} else {
|
||||
console.error('Unhandled rejection (promise: ', event.promise, ', reason: ', event.reason, ').')
|
||||
}
|
||||
event.preventDefault()
|
||||
}
|
||||
80
src/components/Player/player.css
Normal file
80
src/components/Player/player.css
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
.ifplayer {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
|
||||
background-color: var(--bg-color);
|
||||
color: var(--main-color);
|
||||
padding: var(--outer-padding);
|
||||
}
|
||||
|
||||
.ifplayer .inputBox {
|
||||
flex: 0 1 auto;
|
||||
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
outline: 0;
|
||||
|
||||
background-color: var(--bg-color);
|
||||
border: var(--border-width) solid var(--main-color);
|
||||
padding: var(--inner-padding);
|
||||
margin-top: var(--input-box-margin);
|
||||
}
|
||||
|
||||
.ifplayer .inputBox::placeholder {
|
||||
color: var(--main-color);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.ifplayer .inputBox:focus::placeholder {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.ifplayer .inputBox::-webkit-search-cancel-button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ifplayer .textBuffer {
|
||||
flex: 2 1 auto;
|
||||
overflow-y: scroll;
|
||||
box-sizing: border-box;
|
||||
|
||||
border: var(--border-width) solid var(--main-color);
|
||||
padding: var(--inner-padding);
|
||||
|
||||
scrollbar-color: var(--main-color) var(--bg-color);
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.ifplayer .textBuffer::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.ifplayer .textBuffer::-webkit-scrollbar-thumb {
|
||||
background-color: var(--main-color);
|
||||
border: 4px solid var(--bg-color);
|
||||
border-left-width: 0px;
|
||||
}
|
||||
|
||||
.ifplayer .textBuffer > br:first-child,
|
||||
.ifplayer .textBuffer > br:last-child,
|
||||
.ifplayer .textBuffer > br + br + br {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.status {
|
||||
padding: var(--inner-padding);
|
||||
}
|
||||
|
||||
.status.loading > div:after {
|
||||
animation: dots0123 1s infinite;
|
||||
content: '';
|
||||
}
|
||||
|
||||
@keyframes dots0123 {
|
||||
0% { content: ''; }
|
||||
33% { content: '.'; }
|
||||
66% { content: '..'; }
|
||||
100% { content: '...'; }
|
||||
}
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
export default function ThemeSelector ({ themeEngine }) {
|
||||
const options = themeEngine.themes.map((theme) => (
|
||||
<option
|
||||
key={theme}
|
||||
value={theme}>
|
||||
import { h } from 'preact'
|
||||
|
||||
export default function ({ themeEngine }) {
|
||||
const options = themeEngine.themes.map(theme => (
|
||||
<option value={theme}>
|
||||
{theme}
|
||||
</option>))
|
||||
|
||||
|
|
|
|||
60
src/index.js
60
src/index.js
|
|
@ -1,10 +1,62 @@
|
|||
import { render } from 'preact'
|
||||
import { h, render } from 'preact'
|
||||
import { Route, Router, Switch } from 'wouter-preact'
|
||||
|
||||
import {
|
||||
useHashLocation,
|
||||
extractView
|
||||
} from '~/src/utils/utils.routing'
|
||||
import {
|
||||
useThemeEngine
|
||||
} from '~/src/themes/themes'
|
||||
|
||||
import HomeView from '~/src/views/HomeView/HomeView'
|
||||
import GamesView from '~/src/views/GamesView/GamesView'
|
||||
import PlayerView from '~/src/views/PlayerView/PlayerView'
|
||||
import NotFoundView from '~/src/views/NotFoundView'
|
||||
|
||||
import '@fontsource/open-sans'
|
||||
import '~/src/style/base.css'
|
||||
|
||||
import './style/base.scss'
|
||||
import './style/controls.scss'
|
||||
function App () {
|
||||
const themeEngine = useThemeEngine()
|
||||
const [location] = useHashLocation()
|
||||
|
||||
import App from './App'
|
||||
return (
|
||||
<Router hook={useHashLocation}>
|
||||
<div className={[
|
||||
'app',
|
||||
extractView(location),
|
||||
themeEngine.currentTheme].join(' ')}>
|
||||
|
||||
<Switch>
|
||||
<Route path='/'>
|
||||
<HomeView {...{
|
||||
themeEngine
|
||||
}} />
|
||||
</Route>
|
||||
<Route path='/games/'>
|
||||
<GamesView />
|
||||
</Route>
|
||||
<Route path='/play/:encodedUrl'>
|
||||
{params => <PlayerView {...{
|
||||
...themeEngine,
|
||||
...params
|
||||
}} />}
|
||||
</Route>
|
||||
<Route path='/play/:encodedUrl/:theme'>
|
||||
{params => <PlayerView {...{
|
||||
...themeEngine,
|
||||
...params
|
||||
}} />}
|
||||
</Route>
|
||||
<Route>
|
||||
<NotFoundView />
|
||||
</Route>
|
||||
</Switch>
|
||||
|
||||
</div>
|
||||
</Router>
|
||||
)
|
||||
}
|
||||
|
||||
render(<App />, document.getElementById('root'))
|
||||
|
|
|
|||
|
|
@ -1,42 +0,0 @@
|
|||
import {
|
||||
useCallback, useEffect, useState,
|
||||
} from 'preact/hooks'
|
||||
|
||||
const windowLocation = () =>
|
||||
window.location.hash.replace('#', '') || '/'
|
||||
|
||||
export const buildPlayLinkHref = ({ url }) =>
|
||||
`/#/play/${encodeURIComponent(url)}`
|
||||
|
||||
export const extractView = (location) => {
|
||||
if (location === '/') return 'home'
|
||||
|
||||
const currentView = location.split('/').filter(Boolean)[0]
|
||||
|
||||
return currentView || ''
|
||||
}
|
||||
|
||||
export const useHashLocation = () => {
|
||||
const [currentLocation, setCurrentLocation] =
|
||||
useState(windowLocation())
|
||||
|
||||
useEffect(() => {
|
||||
const onHashChange = () => {
|
||||
let newLocation = windowLocation()
|
||||
if (newLocation !== currentLocation) {
|
||||
setCurrentLocation(newLocation)
|
||||
window.scrollTo(0, 0)
|
||||
}
|
||||
}
|
||||
|
||||
onHashChange()
|
||||
window.addEventListener('hashchange', onHashChange)
|
||||
return () => window.removeEventListener('hashchange', onHashChange)
|
||||
}, [currentLocation, setCurrentLocation])
|
||||
|
||||
const navigate = useCallback((to) => {
|
||||
window.location.hash = to.replace('#/', '')
|
||||
}, [])
|
||||
|
||||
return [currentLocation, navigate]
|
||||
}
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
.app {
|
||||
min-height: 100%;
|
||||
box-sizing: border-box;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
color: var(--main-color);
|
||||
background-color: var(--bg-color);
|
||||
|
||||
/* */
|
||||
&.home, &.games, &.themes {
|
||||
padding: var(--inner-padding);
|
||||
|
||||
/* Fix for Jumping Scrollbar Issue */
|
||||
@media (min-width: 800px) {
|
||||
padding-left: calc(100vw - 100% + var(--inner-padding));
|
||||
}
|
||||
}
|
||||
|
||||
/* Player view */
|
||||
&.play, &.focus {
|
||||
height: 100%;
|
||||
max-height: 100dvh;
|
||||
|
||||
@media (min-width: 800px) {
|
||||
& > main {
|
||||
max-height: 90%;
|
||||
margin: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* */
|
||||
& > main {
|
||||
flex: 1 1 auto;
|
||||
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
|
||||
@media (min-width: 800px) {
|
||||
margin: 5vh 0;
|
||||
max-width: 800px;
|
||||
}
|
||||
}
|
||||
}
|
||||
92
src/style/base.css
Normal file
92
src/style/base.css
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
/* Layout */
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
|
||||
font-family: 'Open Sans', sans-serif;
|
||||
font-size: 18px;
|
||||
line-height: 27px;
|
||||
}
|
||||
|
||||
#root {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.app {
|
||||
min-height: 100%;
|
||||
box-sizing: border-box;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
background-color: var(--bg-color);
|
||||
color: var(--main-color);
|
||||
}
|
||||
|
||||
.app > main {
|
||||
flex: 1 1 auto;
|
||||
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
@media (min-width: 800px) {
|
||||
.app > main {
|
||||
margin: 5vh 0;
|
||||
max-width: 800px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Common */
|
||||
|
||||
a,
|
||||
summary {
|
||||
display: inline;
|
||||
cursor: pointer;
|
||||
|
||||
border-bottom: 2px solid currentColor;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:focus,
|
||||
a:hover,
|
||||
summary:focus,
|
||||
summary:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
*:focus {
|
||||
outline: 1px solid var(--main-color);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
*::selection {
|
||||
color: var(--bg-color);
|
||||
background: var(--main-color);
|
||||
}
|
||||
|
||||
input::placeholder {
|
||||
color: var(--main-color);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: square;
|
||||
}
|
||||
|
||||
hr {
|
||||
border: 0;
|
||||
height: 0;
|
||||
border-top: 2px solid var(--main-color);
|
||||
}
|
||||
|
||||
/* */
|
||||
|
||||
.status {
|
||||
padding: 8px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
/* Layout */
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
|
||||
font-family: 'Open Sans', sans-serif;
|
||||
font-size: 18px;
|
||||
line-height: 27px;
|
||||
|
||||
/* To prevent the white flash */
|
||||
background: #000000;
|
||||
}
|
||||
|
||||
#root {
|
||||
height: 100%;
|
||||
}
|
||||
|
|
@ -1,128 +0,0 @@
|
|||
/* Scrollbars */
|
||||
|
||||
body {
|
||||
* {
|
||||
scrollbar-color: var(--main-color) var(--bg-color);
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar {
|
||||
width: var(--inner-padding);
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background-color: var(--main-color);
|
||||
border: calc(0.5 * var(--inner-padding)) solid var(--bg-color);
|
||||
border-left-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Focus */
|
||||
|
||||
*:focus-visible {
|
||||
outline: calc(0.5 * var(--separator-width)) solid var(--main-color);
|
||||
outline-offset: calc(1px + var(--separator-width));
|
||||
}
|
||||
|
||||
*::selection {
|
||||
color: var(--bg-color);
|
||||
background: var(--main-color);
|
||||
}
|
||||
|
||||
/* Links */
|
||||
|
||||
a,
|
||||
summary {
|
||||
display: inline;
|
||||
cursor: pointer;
|
||||
|
||||
border-bottom: var(--separator-width) solid currentColor;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:focus,
|
||||
a:hover,
|
||||
summary:focus,
|
||||
summary:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Separators */
|
||||
|
||||
hr {
|
||||
border: 0;
|
||||
height: 0;
|
||||
border-top: var(--separator-width) solid var(--main-color);
|
||||
}
|
||||
|
||||
/* Lists */
|
||||
|
||||
ul {
|
||||
list-style: square;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
|
||||
button {
|
||||
border: var(--separator-width) solid var(--main-color);
|
||||
background-color: var(--bg-color);
|
||||
color: var(--main-color);
|
||||
padding: var(--inner-padding) calc(4 * var(--inner-padding));
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Inputs */
|
||||
|
||||
input::placeholder {
|
||||
color: var(--main-color);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
input,
|
||||
select {
|
||||
box-sizing: border-box;
|
||||
|
||||
padding: calc(0.5 * var(--inner-padding)) var(--inner-padding);
|
||||
color: var(--main-color);
|
||||
background-color: var(--bg-color);
|
||||
border: var(--separator-width) solid var(--main-color);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
select {
|
||||
width: 250px;
|
||||
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
border-radius: 0;
|
||||
background-image: repeating-linear-gradient(
|
||||
315deg,
|
||||
var(--bg-color),
|
||||
var(--bg-color) var(--inner-padding),
|
||||
var(--main-color) var(--inner-padding),
|
||||
var(--main-color) calc(var(--inner-padding) + var(--separator-width)),
|
||||
var(--bg-color) calc(var(--inner-padding) + var(--separator-width)),
|
||||
var(--bg-color) 100%
|
||||
);
|
||||
}
|
||||
|
||||
label {
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
label input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
input[type='file'] {
|
||||
position: relative;
|
||||
font-size: 0;
|
||||
text-indent: -100%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
/* @see https://github.com/monkeytypegame/monkeytype/blob/master/frontend/static/themes/_list.json */
|
||||
const monkeyTypesThemes = [
|
||||
/* Paste json here! */
|
||||
].map((theme) => theme)
|
||||
.filter((theme) => ![
|
||||
'dark', 'solarized_dark', 'solarized_light',
|
||||
].includes(theme.name))
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
|
||||
const names = monkeyTypesThemes
|
||||
.map(({ name }) => `'${name}',`).join('\n')
|
||||
|
||||
const css = monkeyTypesThemes
|
||||
.map((theme) => [
|
||||
`.${theme.name} {`,
|
||||
` --bg-color: ${theme.bgColor};`,
|
||||
` --main-color: ${theme.textColor};`,
|
||||
` --accent-color: ${theme.mainColor};`,
|
||||
` --input-color: ${theme.subColor};`,
|
||||
'}\n'].join('\n'))
|
||||
.join('\n')
|
||||
|
||||
console.log('/* List*/')
|
||||
console.log(names)
|
||||
|
||||
console.log('/* CSS */')
|
||||
console.log(css)
|
||||
|
|
@ -1,179 +0,0 @@
|
|||
export const themes = [
|
||||
'light',
|
||||
'dim',
|
||||
'dark',
|
||||
/* Solarized */
|
||||
'solarized-light',
|
||||
'solarized-dark',
|
||||
/* Original */
|
||||
'emo',
|
||||
'redrum',
|
||||
'toxin',
|
||||
'valve',
|
||||
'wasp',
|
||||
/* Monkeytype */
|
||||
/* @see https://github.com/monkeytypegame/monkeytype/blob/master/frontend/static/themes/_list.json */
|
||||
'a8008',
|
||||
'a80s_after_dark',
|
||||
'a9009',
|
||||
'aether',
|
||||
'alduin',
|
||||
'alpine',
|
||||
'arch',
|
||||
'aurora',
|
||||
'beach',
|
||||
'bento',
|
||||
'bingsu',
|
||||
'bliss',
|
||||
'blue_dolphin',
|
||||
'blueberry_dark',
|
||||
'blueberry_light',
|
||||
'botanical',
|
||||
'bouquet',
|
||||
'breeze',
|
||||
'bushido',
|
||||
'cafe',
|
||||
'camping',
|
||||
'carbon',
|
||||
'catppuccin',
|
||||
'chaos_theory',
|
||||
'cheesecake',
|
||||
'cherry_blossom',
|
||||
'comfy',
|
||||
'copper',
|
||||
'creamsicle',
|
||||
'cyberspace',
|
||||
'dark_magic_girl',
|
||||
'dark_note',
|
||||
'darling',
|
||||
'deku',
|
||||
'desert_oasis',
|
||||
'dev',
|
||||
'diner',
|
||||
'dino',
|
||||
'dmg',
|
||||
'dollar',
|
||||
'dots',
|
||||
'dracula',
|
||||
'drowning',
|
||||
'dualshot',
|
||||
'earthsong',
|
||||
'everblush',
|
||||
'evil_eye',
|
||||
'ez_mode',
|
||||
'fire',
|
||||
'fledgling',
|
||||
'fleuriste',
|
||||
'froyo',
|
||||
'frozen_llama',
|
||||
'fruit_chew',
|
||||
'fundamentals',
|
||||
'future_funk',
|
||||
'godspeed',
|
||||
'graen',
|
||||
'grand_prix',
|
||||
'gruvbox_dark',
|
||||
'gruvbox_light',
|
||||
'hammerhead',
|
||||
'hanok',
|
||||
'hedge',
|
||||
'honey',
|
||||
'horizon',
|
||||
'husqy',
|
||||
'iceberg_dark',
|
||||
'iceberg_light',
|
||||
'ishtar',
|
||||
'iv_clover',
|
||||
'iv_spade',
|
||||
'joker',
|
||||
'laser',
|
||||
'lavender',
|
||||
'leather',
|
||||
'lil_dragon',
|
||||
'lime',
|
||||
'luna',
|
||||
'magic_girl',
|
||||
'mashu',
|
||||
'matcha_moccha',
|
||||
'material',
|
||||
'matrix',
|
||||
'menthol',
|
||||
'metaverse',
|
||||
'metropolis',
|
||||
'mexican',
|
||||
'miami',
|
||||
'miami_nights',
|
||||
'midnight',
|
||||
'milkshake',
|
||||
'mint',
|
||||
'mizu',
|
||||
'modern_dolch',
|
||||
'modern_dolch_light',
|
||||
'modern_ink',
|
||||
'monokai',
|
||||
'moonlight',
|
||||
'mountain',
|
||||
'mr_sleeves',
|
||||
'ms_cupcakes',
|
||||
'muted',
|
||||
'nautilus',
|
||||
'nebula',
|
||||
'night_runner',
|
||||
'nord',
|
||||
'nord_light',
|
||||
'norse',
|
||||
'oblivion',
|
||||
'olive',
|
||||
'olivia',
|
||||
'onedark',
|
||||
'our_theme',
|
||||
'paper',
|
||||
'passion_fruit',
|
||||
'pastel',
|
||||
'peach_blossom',
|
||||
'peaches',
|
||||
'pink_lemonade',
|
||||
'pulse',
|
||||
'purpurite',
|
||||
'red_dragon',
|
||||
'red_samurai',
|
||||
'repose_dark',
|
||||
'repose_light',
|
||||
'retro',
|
||||
'retrocast',
|
||||
'rose_pine',
|
||||
'rose_pine_dawn',
|
||||
'rose_pine_moon',
|
||||
'rudy',
|
||||
'ryujinscales',
|
||||
'serika',
|
||||
'serika_dark',
|
||||
'sewing_tin',
|
||||
'sewing_tin_light',
|
||||
'shadow',
|
||||
'shoko',
|
||||
'slambook',
|
||||
'snes',
|
||||
'soaring_skies',
|
||||
'sonokai',
|
||||
'stealth',
|
||||
'strawberry',
|
||||
'striker',
|
||||
'superuser',
|
||||
'sweden',
|
||||
'taro',
|
||||
'terminal',
|
||||
'terra',
|
||||
'terror_below',
|
||||
'tiramisu',
|
||||
'trackday',
|
||||
'trance',
|
||||
'tron_orange',
|
||||
'vaporwave',
|
||||
'viridescent',
|
||||
'voc',
|
||||
'vscode',
|
||||
'watermelon',
|
||||
'wavez',
|
||||
'witch_girl',
|
||||
]
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,9 +1,22 @@
|
|||
import { useState } from 'preact/hooks'
|
||||
|
||||
import { themes } from './themeList.js'
|
||||
import './themes.css'
|
||||
|
||||
const LS_THEME_KEY = 'elseifplayer/theme'
|
||||
const themes = [
|
||||
'light',
|
||||
'dim',
|
||||
'dark',
|
||||
'solarized-light',
|
||||
'solarized-dark',
|
||||
'emo',
|
||||
'nord',
|
||||
'redrum',
|
||||
'toxin',
|
||||
'valve',
|
||||
'wasp'
|
||||
]
|
||||
|
||||
const LS_THEME_KEY = 'ifplayer/theme'
|
||||
const DEFAULT_THEME = themes[0]
|
||||
|
||||
const getSavedTheme = () => {
|
||||
|
|
@ -11,7 +24,7 @@ const getSavedTheme = () => {
|
|||
return savedTheme || DEFAULT_THEME
|
||||
}
|
||||
|
||||
const assertTheme = (theme) =>
|
||||
const assertTheme = theme =>
|
||||
themes.includes(theme)
|
||||
? theme
|
||||
: getSavedTheme()
|
||||
|
|
@ -20,17 +33,12 @@ export const useThemeEngine = (initialTheme = getSavedTheme()) => {
|
|||
const [currentTheme, setCurrentTheme] =
|
||||
useState(initialTheme)
|
||||
|
||||
const setTheme = (theme) => {
|
||||
const setTheme = theme => {
|
||||
const newTheme = assertTheme(theme)
|
||||
|
||||
setCurrentTheme(newTheme)
|
||||
localStorage.setItem(LS_THEME_KEY, newTheme)
|
||||
}
|
||||
|
||||
const setRandomTheme = () => {
|
||||
const randomTheme = themes[Math.floor(Math.random() * themes.length)]
|
||||
setTheme(randomTheme)
|
||||
}
|
||||
|
||||
return { currentTheme, setTheme, setRandomTheme, themes }
|
||||
return { currentTheme, setTheme, themes }
|
||||
}
|
||||
|
|
|
|||
30
src/utils/utils.routing.js
Normal file
30
src/utils/utils.routing.js
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import {
|
||||
useState, useEffect, useCallback
|
||||
} from 'preact/hooks'
|
||||
|
||||
export const useHashLocation = () => {
|
||||
const currentLoc = () =>
|
||||
window.location.hash.replace('#', '') || '/'
|
||||
|
||||
const [loc, setLoc] = useState(currentLoc())
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => setLoc(currentLoc())
|
||||
|
||||
window.addEventListener('hashchange', handler)
|
||||
handler()
|
||||
return () => window.removeEventListener('hashchange', handler)
|
||||
}, [])
|
||||
|
||||
const navigate = useCallback(to =>
|
||||
(window.location.hash = to.replace('#/', '')), [])
|
||||
return [loc, navigate]
|
||||
}
|
||||
|
||||
export const buildPlayLinkHref = ({ url }) =>
|
||||
`/#/play/${encodeURIComponent(url)}`
|
||||
|
||||
export const extractView = location => {
|
||||
const currentView = location.split('/').filter(Boolean)[0]
|
||||
return currentView || ''
|
||||
}
|
||||
11
src/views/GamesView/GamesView.css
Normal file
11
src/views/GamesView/GamesView.css
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
.app > .view.games {
|
||||
padding: var(--inner-padding);
|
||||
}
|
||||
|
||||
.view.games h4 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.view.games li {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import { h } from 'preact'
|
||||
import { Link } from 'wouter-preact'
|
||||
|
||||
import GameEntry from
|
||||
|
|
@ -5,25 +6,26 @@ import GameEntry from
|
|||
|
||||
import top2019 from './top2019'
|
||||
|
||||
import * as s from './GamesView.module.scss'
|
||||
import './GamesView.css'
|
||||
|
||||
const tutorialGame = {
|
||||
name: 'The Dreamhold',
|
||||
ifdb: 'https://ifdb.org/viewgame?id=3myqnrs64nbtwdaz',
|
||||
url: 'https://mirror.ifarchive.org/if-archive/games/zcode/dreamhold.z8',
|
||||
url: 'https://mirror.ifarchive.org/if-archive/games/zcode/dreamhold.z8'
|
||||
}
|
||||
|
||||
export default function GamesView () {
|
||||
export default function () {
|
||||
return (
|
||||
<main className={s.games}>
|
||||
<main className='view games'>
|
||||
|
||||
<h1>
|
||||
<a
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
rel='noopener'
|
||||
href='https://ifdb.org/'
|
||||
title='The Interactive Fiction Database'>
|
||||
IFDB
|
||||
</a> Games
|
||||
</a> games
|
||||
</h1>
|
||||
|
||||
<p>
|
||||
|
|
@ -31,25 +33,25 @@ export default function GamesView () {
|
|||
go back</Link>.
|
||||
</p>
|
||||
|
||||
<section className={s.tutorial}>
|
||||
<h2>
|
||||
Tutorial
|
||||
</h2>
|
||||
<h2>
|
||||
Tutorial
|
||||
</h2>
|
||||
|
||||
<p>
|
||||
If you are not familiar with Interactive Fiction,
|
||||
you should start with this tutorial game
|
||||
by Andrew Plotkin:
|
||||
</p>
|
||||
<p>
|
||||
If you are not familiar with Interactive Fiction,
|
||||
you should start with this tutorial game
|
||||
by Andrew Plotkin:
|
||||
</p>
|
||||
|
||||
<ul>
|
||||
<li>
|
||||
<GameEntry {...{
|
||||
...tutorialGame,
|
||||
}} />
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
<ul>
|
||||
<li>
|
||||
<GameEntry {...{
|
||||
...tutorialGame
|
||||
}} />
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<br />
|
||||
|
||||
<h2>
|
||||
Interactive Fiction Top 50 of All Time
|
||||
|
|
@ -58,23 +60,23 @@ export default function GamesView () {
|
|||
<p>
|
||||
<a
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
rel='noopener'
|
||||
href='https://ifdb.org/search?comp&sortby=awn&searchfor=series%3AInteractive+Fiction+Top+50+of+All+Time'>
|
||||
Every four years </a>, Victor Gijsbers puts
|
||||
together a list of the top 50 IF games of all time.
|
||||
|
||||
Here is an almost complete and slightly rearranged version of the <a
|
||||
Here is an almost complete version of the <a
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
rel='noopener'
|
||||
href='https://ifdb.org/viewcomp?id=1lv599reviaxvwo7'>
|
||||
list from 2019</a>:
|
||||
list for 2019</a>:
|
||||
</p>
|
||||
|
||||
<ol>
|
||||
{top2019.map((game) => (
|
||||
<li key={game.name}>
|
||||
{top2019.map(game => (
|
||||
<li>
|
||||
<GameEntry {...{
|
||||
...game,
|
||||
...game
|
||||
}} />
|
||||
</li>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -1,13 +0,0 @@
|
|||
.games {
|
||||
.tutorial {
|
||||
margin-block: 64px;
|
||||
}
|
||||
|
||||
h4 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
li {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,20 +1,20 @@
|
|||
export default [
|
||||
[
|
||||
'Lost Pig',
|
||||
'https://ifdb.org/viewgame?id=mohwfk47yjzii14w',
|
||||
'https://mirror.ifarchive.org/if-archive/games/zcode/LostPig.z8',
|
||||
],
|
||||
[
|
||||
/* Check with cheap-glk */
|
||||
'Counterfeit Monkey',
|
||||
'https://ifdb.org/viewgame?id=aearuuxv83plclpl',
|
||||
'https://mirror.ifarchive.org/if-archive/games/glulx/CounterfeitMonkey.gblorb',
|
||||
'https://mirror.ifarchive.org/if-archive/games/glulx/CounterfeitMonkey.gblorb'
|
||||
],
|
||||
[
|
||||
'Lost Pig',
|
||||
'https://ifdb.org/viewgame?id=mohwfk47yjzii14w',
|
||||
'https://mirror.ifarchive.org/if-archive/games/zcode/LostPig.z8'
|
||||
],
|
||||
[
|
||||
/* Works. Check inputs */
|
||||
'Anchorhead',
|
||||
'https://ifdb.org/viewgame?id=op0uw1gn1tjqmjt7',
|
||||
'https://ifarchive.org/if-archive/games/zcode/anchor.z8',
|
||||
'https://ifarchive.org/if-archive/games/zcode/anchor.z8'
|
||||
],
|
||||
/* [
|
||||
'80 DAYS',
|
||||
|
|
@ -24,18 +24,18 @@ export default [
|
|||
[
|
||||
'Galatea',
|
||||
'https://ifdb.org/viewgame?id=urxrv27t7qtu52lb',
|
||||
'https://mirror.ifarchive.org/if-archive/games/zcode/Galatea.zblorb',
|
||||
'https://mirror.ifarchive.org/if-archive/games/zcode/Galatea.zblorb'
|
||||
],
|
||||
[
|
||||
/* Works. Check inputs */
|
||||
'Photopia',
|
||||
'https://ifdb.org/viewgame?id=ju778uv5xaswnlpl',
|
||||
'https://mirror.ifarchive.org/if-archive/games/zcode/photopia.z5',
|
||||
'https://mirror.ifarchive.org/if-archive/games/zcode/photopia.z5'
|
||||
],
|
||||
[
|
||||
'Spider and Web',
|
||||
'https://ifdb.org/viewgame?id=2xyccw3pe0uovfad',
|
||||
'https://mirror.ifarchive.org/if-archive/games/zcode/Tangle.z5',
|
||||
'https://mirror.ifarchive.org/if-archive/games/zcode/Tangle.z5'
|
||||
],
|
||||
/* [
|
||||
'Trinity',
|
||||
|
|
@ -60,12 +60,12 @@ export default [
|
|||
[
|
||||
'Slouching Towards Bedlam',
|
||||
'https://ifdb.org/viewgame?id=032krqe6bjn5au78',
|
||||
'https://mirror.ifarchive.org/if-archive/games/competition2003/zcode/slouch/slouch.z5',
|
||||
'https://mirror.ifarchive.org/if-archive/games/competition2003/zcode/slouch/slouch.z5'
|
||||
],
|
||||
[
|
||||
'Curses!',
|
||||
'https://ifdb.org/viewgame?id=plvzam05bmz3enh8',
|
||||
'https://mirror.ifarchive.org/if-archive/games/zcode/curses.z5',
|
||||
'https://mirror.ifarchive.org/if-archive/games/zcode/curses.z5'
|
||||
],
|
||||
/* [
|
||||
'howling dogs',
|
||||
|
|
@ -75,12 +75,12 @@ export default [
|
|||
[
|
||||
'Violet',
|
||||
'https://ifdb.org/viewgame?id=4glrrfh7wrp9zz7b',
|
||||
'https://mirror.ifarchive.org/if-archive/games/zcode/Violet.zblorb',
|
||||
'https://mirror.ifarchive.org/if-archive/games/zcode/Violet.zblorb'
|
||||
],
|
||||
[
|
||||
'The Wizard Sniffer',
|
||||
'https://ifdb.org/viewgame?id=uq18rw9gt8j58da',
|
||||
'https://ifarchive.org/if-archive/games/competition2017/The%20Wizard%20Sniffer/The_Wizard_Sniffer.gblorb',
|
||||
'https://ifarchive.org/if-archive/games/competition2017/The%20Wizard%20Sniffer/The_Wizard_Sniffer.gblorb'
|
||||
],
|
||||
/* [
|
||||
'Eat Me',
|
||||
|
|
@ -100,12 +100,12 @@ export default [
|
|||
[
|
||||
'Shade',
|
||||
'https://ifdb.org/viewgame?id=hsfc7fnl40k4a30q',
|
||||
'https://mirror.ifarchive.org/if-archive/games/zcode/shade.z5',
|
||||
'https://mirror.ifarchive.org/if-archive/games/zcode/shade.z5'
|
||||
],
|
||||
[
|
||||
'Vespers',
|
||||
'https://ifdb.org/viewgame?id=6dj2vguyiagrhvc2',
|
||||
'https://mirror.ifarchive.org/if-archive/games/zcode/vespers.z8',
|
||||
'https://mirror.ifarchive.org/if-archive/games/zcode/vespers.z8'
|
||||
],
|
||||
/* [
|
||||
'Will Not Let Me Go',
|
||||
|
|
@ -135,7 +135,7 @@ export default [
|
|||
[
|
||||
'Savoir-Faire',
|
||||
'https://ifdb.org/viewgame?id=p0cizeb3kiwzlm2p',
|
||||
'https://mirror.ifarchive.org/if-archive/games/zcode/Savoir-Faire.zblorb',
|
||||
'https://mirror.ifarchive.org/if-archive/games/zcode/Savoir-Faire.zblorb'
|
||||
],
|
||||
/* [
|
||||
'With Those We Love Alive',
|
||||
|
|
@ -145,7 +145,7 @@ export default [
|
|||
[
|
||||
'Aisle',
|
||||
'https://ifdb.org/viewgame?id=j49crlvd62mhwuzu',
|
||||
'https://mirror.ifarchive.org/if-archive/games/zcode/Aisle.z5',
|
||||
'https://mirror.ifarchive.org/if-archive/games/zcode/Aisle.z5'
|
||||
],
|
||||
/* [
|
||||
'Blue Lacuna',
|
||||
|
|
@ -155,7 +155,7 @@ export default [
|
|||
[
|
||||
'Gun Mute',
|
||||
'https://ifdb.org/viewgame?id=xwedbibfksczn7eq',
|
||||
'https://mirror.ifarchive.org/if-archive/games/tads/GunMute.t3',
|
||||
'https://mirror.ifarchive.org/if-archive/games/tads/GunMute.t3'
|
||||
],
|
||||
/* [
|
||||
'The King of Shreds and Patches',
|
||||
|
|
@ -180,7 +180,7 @@ export default [
|
|||
[
|
||||
'A Beauty Cold and Austere',
|
||||
'https://ifdb.org/viewgame?id=y9y7jozi0l76bb82',
|
||||
'https://ifarchive.org/if-archive/games/competition2017/A%20Beauty%20Cold%20and%20Austere/A_Beauty_Cold_and_Austere.gblorb',
|
||||
'https://ifarchive.org/if-archive/games/competition2017/A%20Beauty%20Cold%20and%20Austere/A_Beauty_Cold_and_Austere.gblorb'
|
||||
],
|
||||
/* [
|
||||
'Cactus Blue Motel',
|
||||
|
|
@ -190,7 +190,7 @@ export default [
|
|||
[
|
||||
'Coloratura',
|
||||
'https://ifdb.org/viewgame?id=g0fl99ovcrq2sqzk',
|
||||
'https://mirror.ifarchive.org/if-archive/games/competition2013/glulx/coloratura/Coloratura.gblorb',
|
||||
'https://mirror.ifarchive.org/if-archive/games/competition2013/glulx/coloratura/Coloratura.gblorb'
|
||||
],
|
||||
/* [
|
||||
'Harmonia',
|
||||
|
|
@ -200,12 +200,12 @@ export default [
|
|||
[
|
||||
'Lime Ergot',
|
||||
'https://ifdb.org/viewgame?id=b8mb4fcwmf1hrxl',
|
||||
'https://mirror.ifarchive.org/if-archive/games/glulx/Lime_Ergot.gblorb',
|
||||
'https://mirror.ifarchive.org/if-archive/games/glulx/Lime_Ergot.gblorb'
|
||||
],
|
||||
[
|
||||
'Rameses',
|
||||
'https://ifdb.org/viewgame?id=0stz0hr7a98bp9mp',
|
||||
'https://mirror.ifarchive.org/if-archive/games/zcode/rameses.zblorb',
|
||||
'https://mirror.ifarchive.org/if-archive/games/zcode/rameses.zblorb'
|
||||
],
|
||||
/* [
|
||||
'Spellbreaker',
|
||||
|
|
@ -220,7 +220,7 @@ export default [
|
|||
[
|
||||
'The Wand',
|
||||
'https://ifdb.org/viewgame?id=2jil5vbxmbv8riv1',
|
||||
'https://ifarchive.org/if-archive/games/glulx/Wand.ulx',
|
||||
'https://ifarchive.org/if-archive/games/glulx/Wand.ulx'
|
||||
],
|
||||
/* [
|
||||
'Zork I',
|
||||
|
|
@ -230,17 +230,17 @@ export default [
|
|||
[
|
||||
'1893: A World\'s Fair Mystery',
|
||||
'https://ifdb.org/viewgame?id=00e0t7swrris5pg6',
|
||||
'https://mirror.ifarchive.org/if-archive/games/tads/1893.gam',
|
||||
'https://mirror.ifarchive.org/if-archive/games/tads/1893.gam'
|
||||
],
|
||||
[
|
||||
'Adventure',
|
||||
'https://ifdb.org/viewgame?id=fft6pu91j85y4acv',
|
||||
'https://mirror.ifarchive.org/if-archive/games/zcode/Advent.z5',
|
||||
'https://mirror.ifarchive.org/if-archive/games/zcode/Advent.z5'
|
||||
],
|
||||
[
|
||||
'Alias \'The Magpie\'',
|
||||
'https://ifdb.org/viewgame?id=yspn49v69hzc8rtb',
|
||||
'https://ifarchive.org/if-archive/games/competition2018/Alias%20The%20Magpie/Alias%20%27The%20Magpie%27.gblorb',
|
||||
'https://ifarchive.org/if-archive/games/competition2018/Alias%20The%20Magpie/Alias%20%27The%20Magpie%27.gblorb'
|
||||
],
|
||||
/* [
|
||||
'De Baron',
|
||||
|
|
@ -255,22 +255,22 @@ export default [
|
|||
[
|
||||
'Cragne Manor',
|
||||
'https://ifdb.org/viewgame?id=4x7nltu8p851tn4x',
|
||||
'https://mirror.ifarchive.org/if-archive/games/glulx/cragne.gblorb',
|
||||
'https://mirror.ifarchive.org/if-archive/games/glulx/cragne.gblorb'
|
||||
],
|
||||
[
|
||||
'The Edifice',
|
||||
'https://ifdb.org/viewgame?id=4tb9soabrb4apqzd',
|
||||
'https://mirror.ifarchive.org/if-archive/games/zcode/edifice.z5',
|
||||
'https://mirror.ifarchive.org/if-archive/games/zcode/edifice.z5'
|
||||
],
|
||||
[
|
||||
'Endless, Nameless',
|
||||
'https://ifdb.org/viewgame?id=7vtm1rq16hh3xch',
|
||||
'https://ifarchive.org/if-archive/games/zcode/nameless.z8',
|
||||
'https://ifarchive.org/if-archive/games/zcode/nameless.z8'
|
||||
],
|
||||
[
|
||||
'Everybody Dies',
|
||||
'https://ifdb.org/viewgame?id=lyblvftb8xtlo0a1',
|
||||
'https://mirror.ifarchive.org/if-archive/games/competition2008/glulx/everybodydies/EverybodyDies.gblorb',
|
||||
'https://mirror.ifarchive.org/if-archive/games/competition2008/glulx/everybodydies/EverybodyDies.gblorb'
|
||||
],
|
||||
/* [
|
||||
'Fallen London',
|
||||
|
|
@ -280,12 +280,12 @@ export default [
|
|||
[
|
||||
'Foo Foo',
|
||||
'https://ifdb.org/viewgame?id=ec6x9y8qcmsrxob9',
|
||||
'https://ifarchive.org/if-archive/games/springthing/2016/FooFoo.gblorb',
|
||||
'https://ifarchive.org/if-archive/games/springthing/2016/FooFoo.gblorb'
|
||||
],
|
||||
[
|
||||
'The Gostak',
|
||||
'https://ifdb.org/viewgame?id=w5s3sv43s3p98v45',
|
||||
'https://mirror.ifarchive.org/if-archive/games/zcode/gostak.z5',
|
||||
'https://mirror.ifarchive.org/if-archive/games/zcode/gostak.z5'
|
||||
],
|
||||
/* [
|
||||
'The Hitchhiker\'s Guide to the Galaxy',
|
||||
|
|
@ -305,27 +305,27 @@ export default [
|
|||
[
|
||||
'Inside the Facility',
|
||||
'https://ifdb.org/viewgame?id=stsdri5zh7a4i5my',
|
||||
'https://ifarchive.org/if-archive/games/competition2016/Inside%20the%20Facility/Facility.z8',
|
||||
'https://ifarchive.org/if-archive/games/competition2016/Inside%20the%20Facility/Facility.z8'
|
||||
],
|
||||
/* [
|
||||
[
|
||||
'Junior Arithmancer',
|
||||
'https://ifdb.org/viewgame?id=pw1rbjt1t4n4n87s',
|
||||
'https://ifarchive.org/if-archive/games/competition2018/Junior%20Arithmancer/Junior_Arithmancer.gblorb',
|
||||
], */
|
||||
'https://ifarchive.org/if-archive/games/competition2018/Junior%20Arithmancer/Junior_Arithmancer.gblorb'
|
||||
],
|
||||
[
|
||||
'Make It Good',
|
||||
'https://ifdb.org/viewgame?id=jdrbw1htq4ah8q57',
|
||||
'https://mirror.ifarchive.org/if-archive/games/zcode/MakeItGood.z8',
|
||||
'https://mirror.ifarchive.org/if-archive/games/zcode/MakeItGood.zblorb'
|
||||
],
|
||||
[
|
||||
'Sub Rosa',
|
||||
'https://ifdb.org/viewgame?id=73nvz9yui87ub3sd',
|
||||
'https://mirror.ifarchive.org/if-archive/games/glulx/Sub_Rosa.gblorb',
|
||||
'https://mirror.ifarchive.org/if-archive/games/glulx/Sub_Rosa.gblorb'
|
||||
],
|
||||
[
|
||||
'Suveh Nux',
|
||||
'https://ifdb.org/viewgame?id=xkai23ry99qdxce3',
|
||||
'https://mirror.ifarchive.org/if-archive/games/zcode/suvehnux.z5',
|
||||
'https://mirror.ifarchive.org/if-archive/games/zcode/suvehnux.z5'
|
||||
],
|
||||
/* [
|
||||
'their angelical understanding',
|
||||
|
|
@ -340,6 +340,6 @@ export default [
|
|||
[
|
||||
'Varicella',
|
||||
'https://ifdb.org/viewgame?id=ywwlr3tpxnktjasd',
|
||||
'https://mirror.ifarchive.org/if-archive/games/zcode/vgame.z8',
|
||||
],
|
||||
'https://mirror.ifarchive.org/if-archive/games/zcode/vgame.z8'
|
||||
]
|
||||
].map(([name, ifdb, url]) => ({ name, ifdb, url }))
|
||||
|
|
|
|||
38
src/views/HomeView/HomeView.css
Normal file
38
src/views/HomeView/HomeView.css
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
.app > .view.home {
|
||||
padding: var(--inner-padding);
|
||||
}
|
||||
|
||||
.view.home input,
|
||||
.view.home select {
|
||||
box-sizing: border-box;
|
||||
|
||||
padding: 4px 8px;
|
||||
color: var(--main-color);
|
||||
background-color: var(--bg-color);
|
||||
border: 2px solid var(--main-color);
|
||||
outline-offset: 0;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.view.home select {
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.view.home label {
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.view.home label input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.view.home input[type='file'] {
|
||||
position: relative;
|
||||
font-size: 0;
|
||||
text-indent: -100%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
|
@ -1,9 +1,10 @@
|
|||
import { h } from 'preact'
|
||||
import { Link } from 'wouter-preact'
|
||||
|
||||
import {
|
||||
useHashLocation,
|
||||
buildPlayLinkHref,
|
||||
} from '~/src/routing'
|
||||
buildPlayLinkHref
|
||||
} from '~/src/utils/utils.routing'
|
||||
|
||||
import LocalFileSelector from
|
||||
'~/src/components/FileSelector/LocalFileSelector'
|
||||
|
|
@ -12,13 +13,15 @@ import TargetURLSelector from
|
|||
import ThemeSelector from
|
||||
'~/src/components/ThemeSelector/ThemeSelector'
|
||||
|
||||
export default function HomeView ({ themeEngine }) {
|
||||
import './HomeView.css'
|
||||
|
||||
export default function ({ themeEngine }) {
|
||||
const setLocation = useHashLocation()[1]
|
||||
|
||||
return (
|
||||
<main>
|
||||
<main className='view home'>
|
||||
<h1>
|
||||
ElseIFPlayer
|
||||
ifplayer
|
||||
</h1>
|
||||
|
||||
<section>
|
||||
|
|
@ -28,8 +31,8 @@ export default function HomeView ({ themeEngine }) {
|
|||
<br />
|
||||
Source code can be found in this <a
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
href='https://github.com/He4eT/elseifplayer'>
|
||||
rel='noopener'
|
||||
href='https://github.com/He4eT/ifplayer'>
|
||||
repository
|
||||
</a>.
|
||||
</p>
|
||||
|
|
@ -39,18 +42,13 @@ export default function HomeView ({ themeEngine }) {
|
|||
|
||||
<section>
|
||||
<h2>
|
||||
Interface Theme
|
||||
Interface theme
|
||||
</h2>
|
||||
|
||||
<ThemeSelector {...{
|
||||
themeEngine,
|
||||
themeEngine
|
||||
}} />
|
||||
|
||||
<p>
|
||||
Preview and choose from available themes on the <Link href={'/#/themes/'}>
|
||||
themes page
|
||||
</Link>.
|
||||
</p>
|
||||
<p>
|
||||
<small>
|
||||
Double-click the input field during the game
|
||||
|
|
@ -63,7 +61,7 @@ export default function HomeView ({ themeEngine }) {
|
|||
|
||||
<section>
|
||||
<h2>
|
||||
Play a Game from the List
|
||||
Play a game from the list
|
||||
</h2>
|
||||
|
||||
<p>
|
||||
|
|
@ -77,19 +75,18 @@ export default function HomeView ({ themeEngine }) {
|
|||
|
||||
<section>
|
||||
<h2>
|
||||
Play the Game from a File
|
||||
Play the game from a file
|
||||
</h2>
|
||||
|
||||
<p>
|
||||
<details>
|
||||
<summary>Supported formats</summary>
|
||||
<p>Text-only games are supported:</p>
|
||||
<ul>
|
||||
<li>ADRIFT 4 (.taf)</li>
|
||||
<li>Glulx (.gblorb, .ulx)</li>
|
||||
<li>Hugo (.hex)</li>
|
||||
<li>TADS 2/3 (.gam, .t3)</li>
|
||||
<li>Z-code (.z3, .z4, .z5, .z8, .blorb)</li>
|
||||
<li>TADS games (.t3, .gam);</li>
|
||||
<li>Z-machine games (.z3, .z4, .z5, .z8, .blorb);</li>
|
||||
<li>Glulx VM games (.gblorb, .ulx);</li>
|
||||
<li>Hugo games (.hex);</li>
|
||||
<li>Text-only games are supported;</li>
|
||||
</ul>
|
||||
</details>
|
||||
</p>
|
||||
|
|
@ -100,7 +97,7 @@ export default function HomeView ({ themeEngine }) {
|
|||
<LocalFileSelector {...{
|
||||
setLocation,
|
||||
buildLink: buildPlayLinkHref,
|
||||
theme: themeEngine.currentTheme,
|
||||
theme: themeEngine.currentTheme
|
||||
}} />
|
||||
</label>
|
||||
</p>
|
||||
|
|
@ -111,7 +108,7 @@ export default function HomeView ({ themeEngine }) {
|
|||
<TargetURLSelector {...{
|
||||
setLocation,
|
||||
buildLink: buildPlayLinkHref,
|
||||
theme: themeEngine.currentTheme,
|
||||
theme: themeEngine.currentTheme
|
||||
}} />
|
||||
</label>
|
||||
</p>
|
||||
|
|
|
|||
26
src/views/NotFoundView.jsx
Normal file
26
src/views/NotFoundView.jsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { h } from 'preact'
|
||||
import { Link } from 'wouter-preact'
|
||||
|
||||
export default () => (
|
||||
<main>
|
||||
<div class='status'>
|
||||
<h1>
|
||||
404
|
||||
</h1>
|
||||
<p>
|
||||
Page not found
|
||||
</p>
|
||||
<hr />
|
||||
<Link href='/'>
|
||||
Home
|
||||
</Link>
|
||||
|
|
||||
<a
|
||||
target='_blank'
|
||||
rel='noopener'
|
||||
href='https://github.com/He4eT/ifplayer/issues'>
|
||||
Report bug
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
import Status from '~/src/components/Player/Status/Status'
|
||||
|
||||
export default function NotFoundView () {
|
||||
return <main>
|
||||
<Status
|
||||
stage='fail'
|
||||
details={['404', 'Page Not Found']}
|
||||
/>
|
||||
</main>
|
||||
}
|
||||
10
src/views/PlayerView/PlayerView.css
Normal file
10
src/views/PlayerView/PlayerView.css
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
.app.play {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@media (min-width: 800px) {
|
||||
.app.play main {
|
||||
max-height: 90%;
|
||||
margin: auto;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,16 +1,14 @@
|
|||
import { h } from 'preact'
|
||||
import { useState, useEffect } from 'preact/hooks'
|
||||
|
||||
import UrlPlayer from '~/src/components/Player/UrlPlayer'
|
||||
import MenuOverlay from '~/src/components/Player/MenuOverlay/MenuOverlay'
|
||||
|
||||
const decode = (encodedUrl) => decodeURIComponent(encodedUrl)
|
||||
import './PlayerView.css'
|
||||
|
||||
export default function PlayerView ({
|
||||
theme, themeEngine, encodedUrl, singleWindow,
|
||||
}) {
|
||||
useEffect(() => {
|
||||
themeEngine.setTheme(theme)
|
||||
}, [theme, themeEngine])
|
||||
const decode = encodedUrl => decodeURIComponent(encodedUrl)
|
||||
|
||||
export default function ({ setTheme, theme, encodedUrl }) {
|
||||
useEffect(() => setTheme(theme), [theme])
|
||||
|
||||
const [targetUrl, setTargetUrl] = useState(decode(encodedUrl))
|
||||
|
||||
|
|
@ -18,26 +16,9 @@ export default function PlayerView ({
|
|||
setTargetUrl(decode(encodedUrl))
|
||||
}, [encodedUrl])
|
||||
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
|
||||
const onFullscreenRequest = () => {
|
||||
document.documentElement.requestFullscreen()
|
||||
}
|
||||
|
||||
return (
|
||||
<main>
|
||||
<MenuOverlay {...{
|
||||
themeEngine,
|
||||
onFullscreenRequest,
|
||||
menuOpen,
|
||||
setMenuOpen,
|
||||
}} />
|
||||
<UrlPlayer {...{
|
||||
url: targetUrl,
|
||||
onFullscreenRequest,
|
||||
setMenuOpen,
|
||||
singleWindow,
|
||||
}} />
|
||||
<UrlPlayer url={targetUrl} />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,73 +0,0 @@
|
|||
import { Link } from 'wouter-preact'
|
||||
|
||||
import * as s from './ThemesView.module.scss'
|
||||
|
||||
const Preview = (themeEngine, theme) =>
|
||||
<section key={theme} className={[s.themePreview, theme].join(' ')}>
|
||||
<div className={s.output}>
|
||||
<div className={[s.message, s.input].join(' ')}>
|
||||
> look
|
||||
</div>
|
||||
<div><br /></div>
|
||||
<div className={[s.message, s.subheader].join(' ')}>
|
||||
{theme}
|
||||
</div>
|
||||
<div>
|
||||
Observe a vibrant demonstration of colors at work,
|
||||
showcasing their versatile usage right before your eyes.
|
||||
</div>
|
||||
<div><br /></div>
|
||||
</div>
|
||||
<button onClick={() => themeEngine.setTheme(theme)}>
|
||||
Apply this colors
|
||||
</button>
|
||||
</section>
|
||||
|
||||
export default function ThemesView ({ themeEngine }) {
|
||||
const themes = themeEngine
|
||||
.themes
|
||||
.map((theme) => Preview(themeEngine, theme))
|
||||
|
||||
return (
|
||||
<main className={s.themes}>
|
||||
<h1>
|
||||
Themes Page
|
||||
</h1>
|
||||
|
||||
<p>
|
||||
Choose one or <Link href='/'>
|
||||
go back</Link>.
|
||||
</p>
|
||||
|
||||
<section className={[s.themePreview, s.current].join(' ')}>
|
||||
<h2>
|
||||
Current Theme
|
||||
</h2>
|
||||
<div className={s.output}>
|
||||
<div className={[s.message, s.input].join(' ')}>
|
||||
> look
|
||||
</div>
|
||||
<div><br /></div>
|
||||
<div className={[s.message, s.subheader].join(' ')}>
|
||||
Selected: {themeEngine.currentTheme}
|
||||
</div>
|
||||
<div>
|
||||
You can set random one with the button below
|
||||
or choose any theme from the list.
|
||||
</div>
|
||||
<div><br /></div>
|
||||
</div>
|
||||
<button onClick={() => themeEngine.setRandomTheme()}>
|
||||
Set a random theme
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<h2>
|
||||
Theme List
|
||||
</h2>
|
||||
<section>
|
||||
{themes}
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
.themes {
|
||||
--current-border: var(--main-color);
|
||||
|
||||
.themePreview {
|
||||
border: 2px solid var(--current-border);
|
||||
padding: calc(2 * var(--inner-padding));
|
||||
margin-bottom: 32px;
|
||||
|
||||
background-color: var(--bg-color);
|
||||
color: var(--main-color);
|
||||
|
||||
&.current {
|
||||
padding: 0;
|
||||
border: none;
|
||||
margin-block: 64px;
|
||||
}
|
||||
|
||||
.output {
|
||||
border: 2px solid var(--main-color);
|
||||
padding: var(--inner-padding);
|
||||
margin-bottom: 8px;
|
||||
|
||||
.message.subheader {
|
||||
font-weight: bold;
|
||||
color: var(--accent-color);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.message.input {
|
||||
color: var(--input-color);
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue