feat: ✨ working prototype
This commit is contained in:
parent
c8d5e899f7
commit
75f6656c38
@ -41,7 +41,6 @@ jobs:
|
|||||||
- run: npm i -g 'npm@${{ steps.versions.outputs.npmVersion }}'
|
- run: npm i -g 'npm@${{ steps.versions.outputs.npmVersion }}'
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
- run: npm run lint
|
- run: npm run lint
|
||||||
- run: npm run stylelint
|
|
||||||
- run: npm run build
|
- run: npm run build
|
||||||
|
|
||||||
release:
|
release:
|
||||||
|
@ -9,40 +9,35 @@ use OCP\AppFramework\Controller;
|
|||||||
use OCP\AppFramework\Http\Attribute\FrontpageRoute;
|
use OCP\AppFramework\Http\Attribute\FrontpageRoute;
|
||||||
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
|
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
|
||||||
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
|
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
|
||||||
|
use OCP\AppFramework\Http\ContentSecurityPolicy;
|
||||||
use OCP\AppFramework\Http\TemplateResponse;
|
use OCP\AppFramework\Http\TemplateResponse;
|
||||||
use OCP\Files\IRootFolder;
|
|
||||||
use OCP\IRequest;
|
use OCP\IRequest;
|
||||||
use OCP\IUserSession;
|
|
||||||
use OCP\Util;
|
use OCP\Util;
|
||||||
|
|
||||||
class PageController extends Controller
|
class PageController extends Controller
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(IRequest $request) {
|
||||||
IRequest $request,
|
|
||||||
private readonly IRootFolder $rootFolder,
|
|
||||||
private readonly IUserSession $userSession
|
|
||||||
) {
|
|
||||||
parent::__construct(Application::APP_ID, $request);
|
parent::__construct(Application::APP_ID, $request);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[NoCSRFRequired]
|
#[NoCSRFRequired]
|
||||||
#[NoAdminRequired]
|
#[NoAdminRequired]
|
||||||
#[FrontpageRoute(verb: 'GET', url: '/{path}', requirements: ['path' => '.+'])]
|
#[FrontpageRoute(verb: 'GET', url: '/')]
|
||||||
public function run(string $path): TemplateResponse {
|
public function run(): TemplateResponse {
|
||||||
$user = $this->userSession->getUser();
|
|
||||||
|
|
||||||
if (!$user) {
|
|
||||||
throw new \Exception('User not logged in');
|
|
||||||
}
|
|
||||||
|
|
||||||
$folder = $this->rootFolder->getUserFolder($user->getUID());
|
|
||||||
$folder->get($path);
|
|
||||||
|
|
||||||
Util::addScript(Application::APP_ID, Application::APP_ID.'-main');
|
Util::addScript(Application::APP_ID, Application::APP_ID.'-main');
|
||||||
|
Util::addStyle(Application::APP_ID, Application::APP_ID.'-main');
|
||||||
|
|
||||||
return new TemplateResponse(
|
$csp = new ContentSecurityPolicy();
|
||||||
Application::APP_ID,
|
$csp->addAllowedConnectDomain('cdn.emulatorjs.org');
|
||||||
'index',
|
$csp->addAllowedConnectDomain('blob:');
|
||||||
);
|
$csp->addAllowedWorkerSrcDomain('blob:');
|
||||||
|
|
||||||
|
/** @psalm-suppress DeprecatedMethod */
|
||||||
|
$csp->allowEvalScript(true);
|
||||||
|
|
||||||
|
$response = new TemplateResponse(Application::APP_ID, 'index');
|
||||||
|
$response->setContentSecurityPolicy($csp);
|
||||||
|
|
||||||
|
return $response;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
1232
package-lock.json
generated
1232
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -10,9 +10,7 @@
|
|||||||
"dev": "vite --mode development build",
|
"dev": "vite --mode development build",
|
||||||
"watch": "vite --mode development build --watch",
|
"watch": "vite --mode development build --watch",
|
||||||
"lint": "tsc && eslint src",
|
"lint": "tsc && eslint src",
|
||||||
"lint:fix": "tsc && eslint src --fix",
|
"lint:fix": "tsc && eslint src --fix"
|
||||||
"stylelint": "stylelint src/**/*.scss src/**/*.css",
|
|
||||||
"stylelint:fix": "stylelint src/**/*.scss src/**/*.css --fix"
|
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"browserslist": [
|
"browserslist": [
|
||||||
@ -20,6 +18,7 @@
|
|||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emulatorjs/emulatorjs": "^4.2.0",
|
"@emulatorjs/emulatorjs": "^4.2.0",
|
||||||
|
"@nextcloud/auth": "^2.4.0",
|
||||||
"@nextcloud/files": "^3.10.1",
|
"@nextcloud/files": "^3.10.1",
|
||||||
"@nextcloud/initial-state": "^2.2.0",
|
"@nextcloud/initial-state": "^2.2.0",
|
||||||
"@nextcloud/l10n": "^3.1.0",
|
"@nextcloud/l10n": "^3.1.0",
|
||||||
@ -31,7 +30,6 @@
|
|||||||
"@nextcloud/browserslist-config": "^3.0.1",
|
"@nextcloud/browserslist-config": "^3.0.1",
|
||||||
"@nextcloud/eslint-config": "^8.4.1",
|
"@nextcloud/eslint-config": "^8.4.1",
|
||||||
"@nextcloud/prettier-config": "^1.1.0",
|
"@nextcloud/prettier-config": "^1.1.0",
|
||||||
"@nextcloud/stylelint-config": "^3.0.1",
|
|
||||||
"eslint-config-prettier": "^9.1.0",
|
"eslint-config-prettier": "^9.1.0",
|
||||||
"eslint-plugin-prettier": "^5.2.1",
|
"eslint-plugin-prettier": "^5.2.1",
|
||||||
"typescript": "~5.5.4"
|
"typescript": "~5.5.4"
|
||||||
|
37
src/init.ts
37
src/init.ts
@ -1,25 +1,42 @@
|
|||||||
import { FileAction, FileType, Node, registerFileAction } from '@nextcloud/files'
|
import {
|
||||||
|
FileAction,
|
||||||
|
FileType,
|
||||||
|
Node,
|
||||||
|
Permission,
|
||||||
|
registerFileAction,
|
||||||
|
} from '@nextcloud/files'
|
||||||
import cores from './mime.json'
|
import cores from './mime.json'
|
||||||
import { generateUrl } from '@nextcloud/router'
|
import { generateUrl } from '@nextcloud/router'
|
||||||
import { t } from '@nextcloud/l10n'
|
import { t } from '@nextcloud/l10n'
|
||||||
|
|
||||||
|
const getCore = (file: Node) => {
|
||||||
|
if (
|
||||||
|
!file.fileid ||
|
||||||
|
file.type !== FileType.File ||
|
||||||
|
!(file.permissions & Permission.READ)
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
const extension = file.extension?.substring(1)
|
||||||
|
for (const core in cores) {
|
||||||
|
if (cores[core].types.includes(file.mime)) return core
|
||||||
|
if (cores[core].extensions.includes(extension)) return core
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
const action = new FileAction({
|
const action = new FileAction({
|
||||||
id: 'retro',
|
id: 'retro',
|
||||||
displayName: () => t('repod', 'Play'),
|
displayName: () => t('repod', 'Play'),
|
||||||
iconSvgInline: () => '',
|
iconSvgInline: () => '',
|
||||||
enabled: (files: Node[]) => {
|
enabled: (files: Node[]) => {
|
||||||
if (files.length !== 1) return false
|
if (files.length !== 1) return false
|
||||||
const file = files[0]
|
return !!getCore(files[0])
|
||||||
if (!file.fileid || file.type !== FileType.File) return false
|
|
||||||
const extension = file.extension?.substring(1)
|
|
||||||
for (const core in cores) {
|
|
||||||
if (cores[core].types.includes(file.mime)) return true
|
|
||||||
if (cores[core].extensions.includes(extension)) return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
},
|
},
|
||||||
exec: async (file: Node) => {
|
exec: async (file: Node) => {
|
||||||
location.href = generateUrl(`/apps/retro/${file.path}`)
|
location.href = generateUrl(`/apps/retro/?core={core}&file={file}`, {
|
||||||
|
core: getCore(file),
|
||||||
|
file: file.encodedSource,
|
||||||
|
})
|
||||||
return true
|
return true
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
323
src/main.ts
323
src/main.ts
@ -0,0 +1,323 @@
|
|||||||
|
import '@emulatorjs/emulatorjs/data/src/emulator.js'
|
||||||
|
import '@emulatorjs/emulatorjs/data/src/nipplejs.js'
|
||||||
|
import '@emulatorjs/emulatorjs/data/src/shaders.js'
|
||||||
|
import '@emulatorjs/emulatorjs/data/src/storage.js'
|
||||||
|
import '@emulatorjs/emulatorjs/data/src/gamepad.js'
|
||||||
|
import '@emulatorjs/emulatorjs/data/src/GameManager.js'
|
||||||
|
import '@emulatorjs/emulatorjs/data/src/socket.io.min.js'
|
||||||
|
import '@emulatorjs/emulatorjs/data/src/compression.js'
|
||||||
|
import '@emulatorjs/emulatorjs/data/emulator.css'
|
||||||
|
|
||||||
|
import af from '@emulatorjs/emulatorjs/data/localization/af-FR.json'
|
||||||
|
import ar from '@emulatorjs/emulatorjs/data/localization/ar-AR.json'
|
||||||
|
import ben from '@emulatorjs/emulatorjs/data/localization/ben-BEN.json'
|
||||||
|
import de from '@emulatorjs/emulatorjs/data/localization/de-GER.json'
|
||||||
|
import el from '@emulatorjs/emulatorjs/data/localization/el-GR.json'
|
||||||
|
import en from '@emulatorjs/emulatorjs/data/localization/en.json'
|
||||||
|
import es from '@emulatorjs/emulatorjs/data/localization/es-ES.json'
|
||||||
|
import fa from '@emulatorjs/emulatorjs/data/localization/fa-AF.json'
|
||||||
|
import { getRequestToken } from '@nextcloud/auth'
|
||||||
|
import hi from '@emulatorjs/emulatorjs/data/localization/hi-HI.json'
|
||||||
|
import it from '@emulatorjs/emulatorjs/data/localization/it-IT.json'
|
||||||
|
import ja from '@emulatorjs/emulatorjs/data/localization/ja-JA.json'
|
||||||
|
import jv from '@emulatorjs/emulatorjs/data/localization/jv-JV.json'
|
||||||
|
import ko from '@emulatorjs/emulatorjs/data/localization/ko-KO.json'
|
||||||
|
import pt from '@emulatorjs/emulatorjs/data/localization/pt-BR.json'
|
||||||
|
import ru from '@emulatorjs/emulatorjs/data/localization/ru-RU.json'
|
||||||
|
import tr from '@emulatorjs/emulatorjs/data/localization/tr-TR.json'
|
||||||
|
import vi from '@emulatorjs/emulatorjs/data/localization/vi-VN.json'
|
||||||
|
import zh from '@emulatorjs/emulatorjs/data/localization/zh-CN.json'
|
||||||
|
|
||||||
|
type Config = {
|
||||||
|
gameUrl?: string
|
||||||
|
dataPath?: string
|
||||||
|
system?: string
|
||||||
|
biosUrl?: string
|
||||||
|
gameName?: string
|
||||||
|
color?: string
|
||||||
|
adUrl?: string
|
||||||
|
adMode?: 0 | 1 | 2
|
||||||
|
adTimer?: number
|
||||||
|
adSize?: string[]
|
||||||
|
alignStartButton?: 'top' | 'center' | 'bottom'
|
||||||
|
VirtualGamepadSettings?: object
|
||||||
|
buttonOpts?: { [key: string]: boolean }
|
||||||
|
volume?: number
|
||||||
|
defaultControllers?: { [key: number]: { [key: number]: string } }
|
||||||
|
startOnLoad?: boolean
|
||||||
|
fullscreenOnLoad?: boolean
|
||||||
|
filePaths?: { [key: string]: string }
|
||||||
|
loadState?: string
|
||||||
|
cacheLimit?: number
|
||||||
|
cheats?: { [key: string]: string }
|
||||||
|
defaultOptions?: { [key: string]: string }
|
||||||
|
gamePatchUrl?: string
|
||||||
|
gameParentUrl?: string
|
||||||
|
netplayUrl?: string
|
||||||
|
gameId?: number
|
||||||
|
backgroundImg?: string
|
||||||
|
backgroundBlur?: boolean
|
||||||
|
backgroundColor?: string
|
||||||
|
controlScheme?:
|
||||||
|
| 'nes'
|
||||||
|
| 'gb'
|
||||||
|
| 'gba'
|
||||||
|
| 'snes'
|
||||||
|
| 'n64'
|
||||||
|
| 'gba'
|
||||||
|
| 'nds'
|
||||||
|
| 'vb'
|
||||||
|
| 'segaMD'
|
||||||
|
| 'segaCD'
|
||||||
|
| 'sega32x'
|
||||||
|
| 'segaMS'
|
||||||
|
| 'segaGG'
|
||||||
|
| 'segaSaturn'
|
||||||
|
| '3do'
|
||||||
|
| 'atari2600'
|
||||||
|
| 'atari7800'
|
||||||
|
| 'lynx'
|
||||||
|
| 'jaguar'
|
||||||
|
| 'arcade'
|
||||||
|
| 'mame'
|
||||||
|
threads?: boolean
|
||||||
|
disableCue?: boolean
|
||||||
|
startBtnName?: string
|
||||||
|
softLoad?: number
|
||||||
|
screenRecording?: { [key: string]: number }
|
||||||
|
externalFiles?: { [key: string]: string }
|
||||||
|
disableDatabases?: boolean
|
||||||
|
disableLocalStorage?: boolean
|
||||||
|
forceLegacyCores?: boolean
|
||||||
|
noAutoFocus?: boolean
|
||||||
|
videoRotation?: 0 | 1 | 2 | 3
|
||||||
|
shaders?: { [key: string]: string }
|
||||||
|
language?:
|
||||||
|
| 'af'
|
||||||
|
| 'ar'
|
||||||
|
| 'ben'
|
||||||
|
| 'de'
|
||||||
|
| 'el'
|
||||||
|
| 'en'
|
||||||
|
| 'es'
|
||||||
|
| 'fa'
|
||||||
|
| 'hi'
|
||||||
|
| 'it'
|
||||||
|
| 'ja'
|
||||||
|
| 'jv'
|
||||||
|
| 'ko'
|
||||||
|
| 'pt'
|
||||||
|
| 'ru'
|
||||||
|
| 'tr'
|
||||||
|
| 'vi'
|
||||||
|
| 'zh'
|
||||||
|
langJson?: { [key: string]: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
const config: Config = {}
|
||||||
|
|
||||||
|
switch (document.documentElement.lang) {
|
||||||
|
case 'af':
|
||||||
|
config.language = 'af'
|
||||||
|
config.langJson = af
|
||||||
|
break
|
||||||
|
case 'ar':
|
||||||
|
config.language = 'ar'
|
||||||
|
config.langJson = ar
|
||||||
|
break
|
||||||
|
case 'ben':
|
||||||
|
config.language = 'ben'
|
||||||
|
config.langJson = ben
|
||||||
|
break
|
||||||
|
case 'de':
|
||||||
|
config.language = 'de'
|
||||||
|
config.langJson = de
|
||||||
|
break
|
||||||
|
case 'el':
|
||||||
|
config.language = 'el'
|
||||||
|
config.langJson = el
|
||||||
|
break
|
||||||
|
case 'es':
|
||||||
|
config.language = 'es'
|
||||||
|
config.langJson = es
|
||||||
|
break
|
||||||
|
case 'fa':
|
||||||
|
config.language = 'fa'
|
||||||
|
config.langJson = fa
|
||||||
|
break
|
||||||
|
case 'hi':
|
||||||
|
config.language = 'hi'
|
||||||
|
config.langJson = hi
|
||||||
|
break
|
||||||
|
case 'it':
|
||||||
|
config.language = 'it'
|
||||||
|
config.langJson = it
|
||||||
|
break
|
||||||
|
case 'ja':
|
||||||
|
config.language = 'ja'
|
||||||
|
config.langJson = ja
|
||||||
|
break
|
||||||
|
case 'jv':
|
||||||
|
config.language = 'jv'
|
||||||
|
config.langJson = jv
|
||||||
|
break
|
||||||
|
case 'ko':
|
||||||
|
config.language = 'ko'
|
||||||
|
config.langJson = ko
|
||||||
|
break
|
||||||
|
case 'pt':
|
||||||
|
config.language = 'pt'
|
||||||
|
config.langJson = pt
|
||||||
|
break
|
||||||
|
case 'ru':
|
||||||
|
config.language = 'ru'
|
||||||
|
config.langJson = ru
|
||||||
|
break
|
||||||
|
case 'tr':
|
||||||
|
config.language = 'tr'
|
||||||
|
config.langJson = tr
|
||||||
|
break
|
||||||
|
case 'vi':
|
||||||
|
config.language = 'vi'
|
||||||
|
config.langJson = vi
|
||||||
|
break
|
||||||
|
case 'zh':
|
||||||
|
config.language = 'zh'
|
||||||
|
config.langJson = zh
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
config.language = 'en'
|
||||||
|
config.langJson = en
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EmulatorJS {
|
||||||
|
new (element: string, config: Config)
|
||||||
|
downloadFile(path, progressCB, notWithPath, opts)
|
||||||
|
on(event: string, func: () => void)
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
EmulatorJS: EmulatorJS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MyEmulatorJS extends window.EmulatorJS {
|
||||||
|
downloadFile(path, progressCB, notWithPath, opts) {
|
||||||
|
// eslint-disable-next-line no-async-promise-executor
|
||||||
|
return new Promise(async (resolve) => {
|
||||||
|
const data = this.toData(path) // check other data types
|
||||||
|
if (data) {
|
||||||
|
data.then((game) => {
|
||||||
|
if (opts.method === 'HEAD') {
|
||||||
|
resolve({ headers: {} })
|
||||||
|
} else {
|
||||||
|
resolve({ headers: {}, data: game })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const basePath = notWithPath ? '' : this.config.dataPath
|
||||||
|
path = basePath + path
|
||||||
|
if (
|
||||||
|
!notWithPath &&
|
||||||
|
this.config.filePaths &&
|
||||||
|
typeof this.config.filePaths[path.split('/').pop()] === 'string'
|
||||||
|
) {
|
||||||
|
path = this.config.filePaths[path.split('/').pop()]
|
||||||
|
}
|
||||||
|
let url
|
||||||
|
try {
|
||||||
|
url = new URL(path)
|
||||||
|
} catch (e) {}
|
||||||
|
if (url && !['http:', 'https:'].includes(url.protocol)) {
|
||||||
|
// Most commonly blob: urls. Not sure what else it could be
|
||||||
|
if (opts.method === 'HEAD') {
|
||||||
|
resolve({ headers: {} })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await fetch(path)
|
||||||
|
let ret
|
||||||
|
if (
|
||||||
|
(opts.type && opts.type.toLowerCase() === 'arraybuffer') ||
|
||||||
|
!opts.type
|
||||||
|
) {
|
||||||
|
ret = await res.arrayBuffer()
|
||||||
|
} else {
|
||||||
|
ret = await res.text()
|
||||||
|
try {
|
||||||
|
ret = JSON.parse(ret)
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
if (path.startsWith('blob:')) URL.revokeObjectURL(path)
|
||||||
|
resolve({ data: ret, headers: {} })
|
||||||
|
} catch (e) {
|
||||||
|
resolve(-1)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const xhr = new XMLHttpRequest()
|
||||||
|
if (progressCB instanceof Function) {
|
||||||
|
xhr.addEventListener('progress', (e) => {
|
||||||
|
const progress = e.total
|
||||||
|
? ' ' +
|
||||||
|
Math.floor((e.loaded / e.total) * 100).toString() +
|
||||||
|
'%'
|
||||||
|
: ' ' + (e.loaded / 1048576).toFixed(2) + 'MB'
|
||||||
|
progressCB(progress)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
xhr.onload = function () {
|
||||||
|
if (xhr.readyState === xhr.DONE) {
|
||||||
|
let data = xhr.response
|
||||||
|
if (
|
||||||
|
xhr.status.toString().startsWith('4') ||
|
||||||
|
xhr.status.toString().startsWith('5')
|
||||||
|
) {
|
||||||
|
resolve(-1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
data = JSON.parse(data)
|
||||||
|
} catch (e) {}
|
||||||
|
resolve({
|
||||||
|
data,
|
||||||
|
headers: {
|
||||||
|
'content-length':
|
||||||
|
xhr.getResponseHeader('content-length'),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (opts.responseType) xhr.responseType = opts.responseType
|
||||||
|
xhr.onerror = () => resolve(-1)
|
||||||
|
xhr.open(opts.method, path, true)
|
||||||
|
// => PATCH <=
|
||||||
|
if (path.includes(location.host)) {
|
||||||
|
xhr.setRequestHeader('requesttoken', getRequestToken() || '')
|
||||||
|
}
|
||||||
|
// => PATCH <=
|
||||||
|
xhr.send()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const gameUrl = new URL(location.href).searchParams.get('file')
|
||||||
|
|
||||||
|
if (gameUrl) {
|
||||||
|
config.gameUrl = gameUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
const system = new URL(location.href).searchParams.get('core')
|
||||||
|
|
||||||
|
if (system) {
|
||||||
|
config.system = system
|
||||||
|
}
|
||||||
|
|
||||||
|
config.dataPath = 'https://cdn.emulatorjs.org/stable/data/'
|
||||||
|
config.startOnLoad = true
|
||||||
|
config.threads = !!window.SharedArrayBuffer
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-new
|
||||||
|
new MyEmulatorJS('#game', config)
|
@ -1,3 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
extends: 'stylelint-config-recommended-vue',
|
|
||||||
}
|
|
@ -1 +1 @@
|
|||||||
<div id="retro"></div>
|
<div id="game"></div>
|
||||||
|
@ -10,8 +10,8 @@ const config = defineConfig({
|
|||||||
|
|
||||||
export default createAppConfig(
|
export default createAppConfig(
|
||||||
{
|
{
|
||||||
main: resolve(join('src', 'main.ts')),
|
|
||||||
init: resolve(join('src', 'init.ts')),
|
init: resolve(join('src', 'init.ts')),
|
||||||
|
main: resolve(join('src', 'main.ts')),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
config,
|
config,
|
||||||
|
Loading…
x
Reference in New Issue
Block a user