├── client ├── src │ ├── util │ │ ├── dispatcher.ts │ │ ├── assertNever.ts │ │ ├── semver.ts │ │ ├── pixiVersionType.ts │ │ ├── queryUtils.ts │ │ └── Storage.ts │ ├── pages │ │ ├── IPageProps.ts │ │ ├── NotFound.tsx │ │ ├── Home.tsx │ │ ├── routes.tsx │ │ ├── Redirect.tsx │ │ ├── Search.tsx │ │ └── Editor.tsx │ ├── index.ts │ ├── components │ │ ├── TopBar.tsx │ │ ├── SearchBar.tsx │ │ ├── Radio.tsx │ │ ├── EditorTopBar.tsx │ │ ├── MonacoEditor.tsx │ │ └── EditorSettingsDialog.tsx │ ├── service │ │ ├── http.ts │ │ └── index.ts │ └── results.ts ├── html │ ├── favicons │ │ ├── favicon.ico │ │ ├── favicon-16x16.png │ │ ├── favicon-32x32.png │ │ ├── mstile-150x150.png │ │ ├── apple-touch-icon.png │ │ ├── android-chrome-96x96.png │ │ ├── browserconfig.xml │ │ ├── manifest.json │ │ └── safari-pinned-tab.svg │ ├── results.ejs │ └── index.ejs ├── README.md ├── tsconfig.json ├── less │ ├── index.less │ ├── spinner.less │ ├── button.less │ ├── toggle.less │ ├── alert.less │ ├── editor.less │ └── dialog.less ├── package.json └── webpack.config.js ├── server ├── nodemon.json ├── src │ ├── lib │ │ ├── db-logger.ts │ │ ├── HttpError.ts │ │ ├── db.ts │ │ ├── logger.ts │ │ └── cloudflare.ts │ ├── models │ │ ├── index.ts │ │ ├── PlaygroundTag.ts │ │ ├── Tag.ts │ │ ├── ExternalJs.ts │ │ └── Playground.ts │ ├── migrations │ │ ├── 02_AutoUpdate.ts │ │ ├── 01_ExternalJs.ts │ │ └── 00_Initial.ts │ ├── routes │ │ ├── index.ts │ │ ├── tags.ts │ │ └── playgrounds.ts │ ├── migrate.ts │ ├── server.ts │ ├── config.ts │ ├── app.ts │ └── middleware.ts ├── vitest.config.ts ├── test │ ├── specs │ │ └── routes │ │ │ ├── index.ts │ │ │ ├── tags.ts │ │ │ └── playgrounds.ts │ └── fixtures │ │ └── server.ts ├── ecosystem.config.js ├── tsconfig.json ├── README.md ├── pixiplayground.com.conf ├── package.json └── droplet-setup.sh ├── .editorconfig ├── .travis.yml ├── .gitignore ├── deploy.sh ├── shared └── types.ts ├── package.sh ├── LICENSE ├── README.md ├── CODE_OF_CONDUCT.md └── tslint.json /client/src/util/dispatcher.ts: -------------------------------------------------------------------------------- 1 | export default {} 2 | -------------------------------------------------------------------------------- /client/html/favicons/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/englercj/playground/main/client/html/favicons/favicon.ico -------------------------------------------------------------------------------- /client/src/pages/IPageProps.ts: -------------------------------------------------------------------------------- 1 | export interface IPageProps 2 | { 3 | path?: string; 4 | default?: boolean; 5 | } 6 | -------------------------------------------------------------------------------- /server/nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "watch": ["src"], 3 | "ext": "ts", 4 | "exec": "ts-node --files ./src/app.ts" 5 | } 6 | -------------------------------------------------------------------------------- /client/html/favicons/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/englercj/playground/main/client/html/favicons/favicon-16x16.png -------------------------------------------------------------------------------- /client/html/favicons/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/englercj/playground/main/client/html/favicons/favicon-32x32.png -------------------------------------------------------------------------------- /client/html/favicons/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/englercj/playground/main/client/html/favicons/mstile-150x150.png -------------------------------------------------------------------------------- /client/html/favicons/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/englercj/playground/main/client/html/favicons/apple-touch-icon.png -------------------------------------------------------------------------------- /client/src/util/assertNever.ts: -------------------------------------------------------------------------------- 1 | export function assertNever(x: never): never 2 | { 3 | throw new Error("Unexpected object: " + x); 4 | } 5 | -------------------------------------------------------------------------------- /server/src/lib/db-logger.ts: -------------------------------------------------------------------------------- 1 | import { logger } from './logger'; 2 | 3 | export const dbLogger = logger.child({ sequelize: true }, true); 4 | -------------------------------------------------------------------------------- /client/html/favicons/android-chrome-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/englercj/playground/main/client/html/favicons/android-chrome-96x96.png -------------------------------------------------------------------------------- /client/src/index.ts: -------------------------------------------------------------------------------- 1 | import '../less/index.less'; 2 | 3 | import { render } from 'preact'; 4 | 5 | import routes from './pages/routes'; 6 | 7 | render(routes, document.getElementById('jsx-wrapper')); 8 | -------------------------------------------------------------------------------- /client/src/util/semver.ts: -------------------------------------------------------------------------------- 1 | export const rgxSemVer = /^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/; 2 | -------------------------------------------------------------------------------- /server/src/lib/HttpError.ts: -------------------------------------------------------------------------------- 1 | export class HttpError extends Error 2 | { 3 | httpCode: number; 4 | 5 | constructor(httpCode: number, msg: string) 6 | { 7 | super(msg); 8 | 9 | this.httpCode = httpCode; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /server/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config' 2 | 3 | export default defineConfig({ 4 | test: { 5 | include: ['test/specs/**/*.ts'], 6 | fileParallelism: false, 7 | silent: false, 8 | }, 9 | }); 10 | -------------------------------------------------------------------------------- /client/src/pages/NotFound.tsx: -------------------------------------------------------------------------------- 1 | import { h, Component } from 'preact'; 2 | import { IPageProps } from './IPageProps'; 3 | 4 | export class NotFound extends Component 5 | { 6 | render() 7 | { 8 | return
404: Not Found
; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /client/html/favicons/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #e91e63 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /client/README.md: -------------------------------------------------------------------------------- 1 | # Usage 2 | 3 | ## Running a development server: 4 | 5 | To run the dev server you just run: 6 | 7 | ``` 8 | $> npm run dev 9 | ``` 10 | 11 | This will launch the webpack dev server at `http://localhost:8080`. 12 | 13 | Make sure to run the server application to if you want to save/load playgrounds. 14 | -------------------------------------------------------------------------------- /server/src/lib/db.ts: -------------------------------------------------------------------------------- 1 | import { Sequelize } from 'sequelize-typescript'; 2 | import { db as dbConfig } from '../config'; 3 | import { dbLogger } from './db-logger'; 4 | 5 | dbConfig.logging = (msg: string, ms: number) => { 6 | dbLogger.debug({ timing: ms }, msg); 7 | }; 8 | 9 | const sequelize = new Sequelize(dbConfig); 10 | 11 | export const db = sequelize; 12 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # This file is for unifying the coding style for different editors and IDEs. 2 | # More information at http://EditorConfig.org 3 | root = true 4 | 5 | [*] 6 | end_of_line = lf 7 | insert_final_newline = true 8 | trim_trailing_whitespace = true 9 | indent_style = space 10 | indent_size = 4 11 | 12 | [{package.json,bower.json,*.yml}] 13 | indent_size = 2 14 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | 3 | language: node_js 4 | 5 | node_js: 6 | - '10' 7 | - '12' 8 | 9 | env: 10 | - TEST_DIR=client 11 | - TEST_DIR=server 12 | 13 | cache: 14 | directories: 15 | - $TEST_DIR/node_modules 16 | 17 | before_install: 18 | - cd $TEST_DIR 19 | 20 | install: 21 | - npm install 22 | 23 | script: 24 | - npm run build 25 | - npm test 26 | -------------------------------------------------------------------------------- /server/test/specs/routes/index.ts: -------------------------------------------------------------------------------- 1 | import * as CODES from 'http-codes'; 2 | import { suite, test } from 'vitest'; 3 | import { request } from '../../fixtures/server'; 4 | 5 | suite('Read Routes', () => 6 | { 7 | test('GET health API should return 200', async () => 8 | { 9 | return request.get('/api/health') 10 | .expect(CODES.OK); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /client/html/favicons/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Playground", 3 | "name": "Pixi Playground", 4 | "icons": [ 5 | { 6 | "src": "/android-chrome-96x96.png", 7 | "sizes": "96x96", 8 | "type": "image/png" 9 | } 10 | ], 11 | "theme_color": "#ffffff", 12 | "background_color": "#ffffff", 13 | "display": "standalone" 14 | } 15 | -------------------------------------------------------------------------------- /client/src/pages/Home.tsx: -------------------------------------------------------------------------------- 1 | import { h, Component } from 'preact'; 2 | import { IPageProps } from './IPageProps'; 3 | import { TopBar } from '../components/TopBar'; 4 | 5 | export class Home extends Component 6 | { 7 | render() 8 | { 9 | return ( 10 |
11 | 12 | Home 13 |
14 | ); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /server/ecosystem.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | apps : [{ 3 | name: 'service', 4 | script: 'app.js', 5 | instances: 'max', 6 | merge_logs: true, 7 | wait_ready: true, 8 | listen_timeout: 2000, 9 | env_production : { 10 | NODE_ENV: 'production', 11 | PORT: '3000', 12 | HOST: '127.0.0.1' 13 | } 14 | }] 15 | }; 16 | -------------------------------------------------------------------------------- /server/src/models/index.ts: -------------------------------------------------------------------------------- 1 | import { Sequelize } from 'sequelize-typescript'; 2 | 3 | import { ExternalJs } from './ExternalJs'; 4 | import { Playground } from './Playground'; 5 | import { PlaygroundTag } from './PlaygroundTag'; 6 | import { Tag } from './Tag'; 7 | 8 | export function setupModels(db: Sequelize) 9 | { 10 | db.addModels([ 11 | ExternalJs, 12 | Playground, 13 | PlaygroundTag, 14 | Tag, 15 | ]); 16 | } 17 | -------------------------------------------------------------------------------- /client/src/util/pixiVersionType.ts: -------------------------------------------------------------------------------- 1 | import { rgxSemVer } from '../util/semver'; 2 | 3 | export enum PixiVersionType 4 | { 5 | Release, 6 | Tag, 7 | Custom, 8 | } 9 | 10 | export function getPixiVersionType(version: string): PixiVersionType 11 | { 12 | if (version === 'release') 13 | return PixiVersionType.Release; 14 | 15 | if (version.match(rgxSemVer) !== null) 16 | return PixiVersionType.Tag; 17 | 18 | return PixiVersionType.Custom; 19 | } 20 | -------------------------------------------------------------------------------- /client/src/util/queryUtils.ts: -------------------------------------------------------------------------------- 1 | export function getQueryParam(name: string, search: string = window.location.href) 2 | { 3 | if (!search) 4 | return null; 5 | 6 | name = name.replace(/[\[\]]/g, '\\$&'); 7 | 8 | const regex = new RegExp(`[?&]${name}(=([^&#]*)|&|#|$)`); 9 | const results = regex.exec(search); 10 | 11 | if (!results) 12 | return null; 13 | 14 | if (!results[2]) 15 | return ''; 16 | 17 | return decodeURIComponent(results[2].replace(/\+/g, ' ')); 18 | } 19 | -------------------------------------------------------------------------------- /client/src/components/TopBar.tsx: -------------------------------------------------------------------------------- 1 | import { h, Component } from 'preact'; 2 | import { SearchBar } from './SearchBar'; 3 | 4 | interface IProps 5 | { 6 | searchText?: string; 7 | useHistoryReplace?: boolean; 8 | } 9 | 10 | export class TopBar extends Component 11 | { 12 | render() 13 | { 14 | return ( 15 |
16 | 17 |
18 | ); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # sublime text files 2 | *.sublime* 3 | *.*~*.TMP 4 | 5 | # temp files 6 | .DS_Store 7 | Thumbs.db 8 | Desktop.ini 9 | npm-debug.log 10 | 11 | # project files 12 | .project 13 | .idea 14 | 15 | # vim swap files 16 | *.sw* 17 | 18 | # emacs temp files 19 | *~ 20 | \#*# 21 | 22 | # Elastic Beanstalk Files 23 | .elasticbeanstalk/* 24 | !.elasticbeanstalk/*.cfg.yml 25 | !.elasticbeanstalk/*.global.yml 26 | 27 | # project ignores 28 | !.gitkeep 29 | *__temp 30 | *.sqlite 31 | *.zip 32 | .snyk 33 | .commit 34 | .records 35 | server.env 36 | node_modules/ 37 | dist/ 38 | -------------------------------------------------------------------------------- /client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "module": "commonjs", 5 | "moduleResolution": "node", 6 | "jsx": "react", 7 | "jsxFactory": "h", 8 | "sourceMap": false, 9 | "declaration": false, 10 | "noImplicitAny": true, 11 | "removeComments": true, 12 | "inlineSourceMap": false, 13 | "experimentalDecorators": true, 14 | "emitDecoratorMetadata": true 15 | }, 16 | "include": [ 17 | "../typings/**/*", 18 | "src/**/*" 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /server/src/migrations/02_AutoUpdate.ts: -------------------------------------------------------------------------------- 1 | import { QueryInterface, DataTypes } from 'sequelize'; 2 | import { MigrationParams } from 'umzug'; 3 | 4 | export async function up({ context: queryInterface }: MigrationParams) 5 | { 6 | await queryInterface.addColumn('playgrounds', 'autoUpdate', { 7 | type: DataTypes.BOOLEAN, 8 | defaultValue: true, 9 | }); 10 | } 11 | 12 | export async function down({ context: queryInterface }: MigrationParams) 13 | { 14 | await queryInterface.removeColumn('playgrounds', 'autoUpdate'); 15 | } 16 | -------------------------------------------------------------------------------- /server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "module": "CommonJS", 5 | "moduleResolution": "Node", 6 | "sourceMap": false, 7 | "declaration": false, 8 | "noImplicitAny": true, 9 | "removeComments": true, 10 | "inlineSourceMap": false, 11 | "experimentalDecorators": true, 12 | "emitDecoratorMetadata": true, 13 | "esModuleInterop": true 14 | }, 15 | "include": [ 16 | "../typings/**/*", 17 | "typings/**/*", 18 | "src/**/*" 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /deploy.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | if [ "$1" != "client" ] && [ "$1" != "server" ] && [ "$1" != "both" ] ; then 4 | echo "Usage: deploy.sh " 5 | exit 1 6 | fi 7 | 8 | if [ "$2" != "--skip-package" ] ; then 9 | ./package.sh "$1" 10 | fi 11 | 12 | if [ "$1" == "client" ] || [ "$1" == "both" ] ; then 13 | echo "Deploying client..." 14 | NODE_ENV=production node ./build/deploy.js client 15 | fi 16 | 17 | if [ "$1" == "server" ] || [ "$1" == "both" ] ; then 18 | echo "Deploying server..." 19 | NODE_ENV=production node ./build/deploy.js server 20 | fi 21 | 22 | echo "Done" 23 | -------------------------------------------------------------------------------- /server/README.md: -------------------------------------------------------------------------------- 1 | # Usage 2 | 3 | ## Running a development server: 4 | 5 | To run the dev server you just run: 6 | 7 | ``` 8 | $> npm run dev 9 | ``` 10 | 11 | This will launch `nodemon`, which will monitor the ts source files and execute `ts-node` 12 | to run the server. Any changes to the typescript source will relaunch the server. 13 | 14 | Overhead from `nodemon` and `ts-node` will cause longer startup times and reduced performance. 15 | 16 | ## Building the server 17 | 18 | Building the source typescript is done with the following command: 19 | 20 | ``` 21 | $> npm run build 22 | ``` 23 | 24 | This outputs the server code to `dist/`. 25 | -------------------------------------------------------------------------------- /server/src/routes/index.ts: -------------------------------------------------------------------------------- 1 | // TODO: Optimistic locking failure retries! 2 | 3 | import * as CODES from 'http-codes'; 4 | import * as restify from 'restify'; 5 | 6 | import { setupRoutes as playgroundRoutes } from './playgrounds'; 7 | import { setupRoutes as tagRoutes } from './tags'; 8 | 9 | export function setupRoutes(app: restify.Server) 10 | { 11 | /** 12 | * GET /health 13 | * 14 | * Returns 200 for AWS health checks. 15 | */ 16 | app.get('/api/health', async (req, res) => 17 | { 18 | res.send(CODES.OK); 19 | }); 20 | 21 | // Child routes 22 | playgroundRoutes(app); 23 | tagRoutes(app); 24 | }; 25 | -------------------------------------------------------------------------------- /shared/types.ts: -------------------------------------------------------------------------------- 1 | export interface IPlayground 2 | { 3 | id?: number; 4 | slug?: string; 5 | name?: string; 6 | description?: string; 7 | contents?: string; 8 | author?: string; 9 | versionsCount?: number; 10 | starCount?: number; 11 | pixiVersion?: string; 12 | isPublic?: boolean; 13 | isFeatured?: boolean; 14 | isOfficial?: boolean; 15 | autoUpdate?: boolean; 16 | 17 | tags?: ITag[]; 18 | externaljs?: IExternalJs[]; 19 | } 20 | 21 | export interface IExternalJs 22 | { 23 | id?: number; 24 | url?: string; 25 | } 26 | 27 | export interface ITag 28 | { 29 | id?: number; 30 | name?: string; 31 | } 32 | -------------------------------------------------------------------------------- /client/less/index.less: -------------------------------------------------------------------------------- 1 | @import 'alert.less'; 2 | @import 'dialog.less'; 3 | @import 'spinner.less'; 4 | @import 'editor.less'; 5 | @import 'button.less'; 6 | 7 | * { 8 | box-sizing: border-box; 9 | } 10 | 11 | html, body { 12 | font-family: 'Roboto', Helvetica, sans-serif; 13 | font-size: 1em; 14 | 15 | height: 100%; 16 | margin: 0; 17 | padding: 0; 18 | overflow: hidden; 19 | } 20 | 21 | .fullscreen { 22 | position: absolute; 23 | top: 0; 24 | left: 0; 25 | width: 100%; 26 | height: 100%; 27 | z-index: 1000; 28 | } 29 | 30 | .fullheight { 31 | height: 100%; 32 | } 33 | 34 | .hidden { 35 | display: none; 36 | } 37 | -------------------------------------------------------------------------------- /server/src/models/PlaygroundTag.ts: -------------------------------------------------------------------------------- 1 | import { DataType, Table, Column, Model, ForeignKey } from 'sequelize-typescript'; 2 | import { Tag } from './Tag'; 3 | import { Playground } from './Playground'; 4 | 5 | @Table({ 6 | tableName: 'playground_tags', 7 | modelName: 'playgroundtag', 8 | timestamps: true, 9 | }) 10 | export class PlaygroundTag extends Model 11 | { 12 | @ForeignKey(() => Playground) 13 | @Column({ 14 | type: DataType.INTEGER, 15 | }) 16 | declare playgroundId: number; 17 | 18 | @ForeignKey(() => Tag) 19 | @Column({ 20 | type: DataType.INTEGER, 21 | }) 22 | declare tagId: number; 23 | } 24 | -------------------------------------------------------------------------------- /client/src/pages/routes.tsx: -------------------------------------------------------------------------------- 1 | import { h, render } from 'preact'; 2 | import { createHashHistory } from 'history'; 3 | import { Router } from 'preact-router'; 4 | 5 | import { Editor } from './Editor'; 6 | // import { Home } from './Home'; 7 | import { NotFound } from './NotFound'; 8 | import { Redirect } from './Redirect'; 9 | import { Search } from './Search'; 10 | 11 | export default ( 12 | 13 | {/* */} 14 | 15 | 16 | 17 | 18 | 19 | 20 | ) 21 | -------------------------------------------------------------------------------- /client/less/spinner.less: -------------------------------------------------------------------------------- 1 | @keyframes spinner { 2 | to { 3 | transform: rotate(360deg); 4 | } 5 | } 6 | 7 | .spinner(@size, @border-size) { 8 | &::before { 9 | content: ''; 10 | display: inline-block; 11 | box-sizing: border-box; 12 | width: @size; 13 | height: @size; 14 | border-radius: 50%; 15 | border: @border-size solid #ccc; 16 | border-top-color: #07d; 17 | animation: spinner .6s linear infinite; 18 | } 19 | 20 | &.centered::before { 21 | position: absolute; 22 | top: 50%; 23 | left: 50%; 24 | margin-top: unit(-(@size / 2), px); 25 | margin-left: unit(-(@size / 2), px); 26 | } 27 | } 28 | 29 | -------------------------------------------------------------------------------- /client/src/util/Storage.ts: -------------------------------------------------------------------------------- 1 | export namespace Storage 2 | { 3 | const localStorageExists = (() => typeof localStorage !== 'undefined')(); 4 | 5 | export function get(key: string): string | null 6 | { 7 | if (!localStorageExists) 8 | return null; 9 | 10 | return localStorage.getItem(key); 11 | } 12 | 13 | export function set(key: string, value: string): void 14 | { 15 | if (!localStorageExists) 16 | return null; 17 | 18 | localStorage.setItem(key, value); 19 | } 20 | 21 | export function remove(key: string): void 22 | { 23 | if (!localStorageExists) 24 | return null; 25 | 26 | localStorage.removeItem(key); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /server/src/models/Tag.ts: -------------------------------------------------------------------------------- 1 | import { Table, Column, Model, CreatedAt, UpdatedAt, BelongsToMany, DataType } from 'sequelize-typescript'; 2 | import { ITag } from '../../../shared/types'; 3 | import { Playground } from './Playground'; 4 | import { PlaygroundTag } from './PlaygroundTag'; 5 | 6 | @Table({ 7 | tableName: 'tags', 8 | modelName: 'tag', 9 | timestamps: true, 10 | }) 11 | export class Tag extends Model implements ITag 12 | { 13 | @BelongsToMany(() => Playground, () => PlaygroundTag) 14 | declare playgrounds: Playground[]; 15 | 16 | /** 17 | * The user-defined name of the tag. 18 | * 19 | */ 20 | @Column({ 21 | type: DataType.STRING(255), 22 | }) 23 | declare name: string; 24 | } 25 | -------------------------------------------------------------------------------- /package.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | if [ "$1" != "client" ] && [ "$1" != "server" ] && [ "$1" != "both" ] ; then 4 | echo "Usage: package.sh " 5 | exit 1 6 | fi 7 | 8 | echo "Ensuring build dependencies are installed..." 9 | cd build && 10 | npm i && 11 | cd .. 12 | 13 | if [ "$1" == "client" ] || [ "$1" == "both" ] ; then 14 | echo "Building client..." 15 | cd client && 16 | npm i && 17 | npm run build && 18 | cd .. && 19 | node ./build/package.js client 20 | fi 21 | 22 | if [ "$1" == "server" ] || [ "$1" == "both" ] ; then 23 | echo "Building server..." 24 | cd server && 25 | npm i -f && 26 | npm run build && 27 | cd .. && 28 | node ./build/package.js server 29 | fi 30 | 31 | echo "Done" 32 | -------------------------------------------------------------------------------- /client/src/pages/Redirect.tsx: -------------------------------------------------------------------------------- 1 | import { h, Component, ComponentChild } from 'preact'; 2 | import { route } from 'preact-router'; 3 | 4 | interface IProps 5 | { 6 | to: string; 7 | } 8 | 9 | export class Redirect extends Component 10 | { 11 | componentWillMount() 12 | { 13 | // This set timeout works around an issue with the router where we may mount 14 | // before routes are ready and redirect too early. 15 | // See: https://github.com/preactjs/preact-router/issues/291 16 | // See: https://github.com/preactjs/preact-router/issues/302 17 | setTimeout(() => this.redirect(), 100); 18 | } 19 | 20 | redirect() 21 | { 22 | route(this.props.to, true); 23 | } 24 | 25 | render(): ComponentChild 26 | { 27 | return null; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /server/test/fixtures/server.ts: -------------------------------------------------------------------------------- 1 | process.env.NODE_ENV = 'test'; 2 | 3 | import * as restify from 'restify'; 4 | import supertest from 'supertest'; 5 | import { Sequelize } from 'sequelize'; 6 | import { beforeAll, afterAll } from 'vitest'; 7 | import { start, close } from '../../src/server'; 8 | import { db } from '../../src/lib/db'; 9 | 10 | let app: restify.Server = null; 11 | export let request: supertest.Agent = null; 12 | 13 | // run the server before all tests 14 | beforeAll(async () => 15 | { 16 | await db.sync({ force: true }); 17 | const appInstance = await start(); 18 | 19 | app = appInstance; 20 | request = supertest(app); 21 | }); 22 | 23 | // close server when tests complete 24 | afterAll(async () => 25 | { 26 | await close(); 27 | }); 28 | 29 | export function clearDb(): Promise 30 | { 31 | return db.sync({ force: true }); 32 | } 33 | -------------------------------------------------------------------------------- /server/src/models/ExternalJs.ts: -------------------------------------------------------------------------------- 1 | import { Table, Column, Model, ForeignKey, BelongsTo, DataType } from 'sequelize-typescript'; 2 | import { Playground } from './Playground'; 3 | 4 | @Table({ 5 | tableName: 'external_js', 6 | modelName: 'external_js', 7 | timestamps: true, 8 | }) 9 | export class ExternalJs extends Model 10 | { 11 | /** 12 | * The user-defined name of the tag. 13 | * 14 | */ 15 | @Column({ 16 | type: DataType.STRING(1023), 17 | allowNull: false, 18 | }) 19 | declare url: string; 20 | 21 | /** 22 | * The playground this external js object belongs to. 23 | * 24 | */ 25 | @ForeignKey(() => Playground) 26 | @Column({ 27 | type: DataType.INTEGER, 28 | }) 29 | declare playgroundId: number; 30 | 31 | @BelongsTo(() => Playground) 32 | declare playground: Playground; 33 | } 34 | -------------------------------------------------------------------------------- /client/src/components/SearchBar.tsx: -------------------------------------------------------------------------------- 1 | import { h, Component } from 'preact'; 2 | import { route } from 'preact-router'; 3 | import { bind } from 'decko'; 4 | 5 | interface IProps 6 | { 7 | text?: string; 8 | useHistoryReplace?: boolean; 9 | } 10 | 11 | export class SearchBar extends Component 12 | { 13 | private _searchInput: HTMLInputElement; 14 | 15 | @bind 16 | onSearchButtonMount(input: HTMLInputElement) 17 | { 18 | this._searchInput = input; 19 | } 20 | 21 | @bind 22 | performSearch() 23 | { 24 | route(`/search?q=${encodeURIComponent(this._searchInput.value)}`, !!this.props.useHistoryReplace); 25 | } 26 | 27 | render() 28 | { 29 | return ( 30 |
31 | 32 | 33 |
34 | ); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /server/src/migrate.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import { Umzug, SequelizeStorage } from 'umzug'; 3 | import { logger } from './lib/logger'; 4 | import { db } from './lib/db'; 5 | 6 | const migrationsGlob = path.join(__dirname, 'migrations', '*.{ts,js}'); 7 | 8 | const umzugLogger = logger.child({ umzug: true }, true); 9 | const umzug = new Umzug({ 10 | storage: new SequelizeStorage({ 11 | sequelize: db, 12 | tableName: 'schema_migrations', 13 | columnName: 'migration', 14 | }), 15 | logger: umzugLogger, 16 | context: db.getQueryInterface(), 17 | migrations: { 18 | // The version of glob that umzug uses requires forward slashes. 19 | glob: migrationsGlob.replace(/\\/g, '/'), 20 | }, 21 | }); 22 | 23 | (async () => 24 | { 25 | try 26 | { 27 | await umzug.up(); 28 | await db.close(); 29 | } 30 | catch (err) 31 | { 32 | logger.fatal({ err }, 'Umzug migration failure.'); 33 | process.exit(1); 34 | } 35 | })(); 36 | -------------------------------------------------------------------------------- /server/src/lib/logger.ts: -------------------------------------------------------------------------------- 1 | import cluster from 'cluster'; 2 | // import * as createCWStream from 'bunyan-cloudwatch'; 3 | import * as config from '../config'; 4 | import * as bunyan from 'bunyan'; 5 | 6 | const bunyanConfig: any = { 7 | name: 'service', 8 | level: 'debug', 9 | serializers: bunyan.stdSerializers, 10 | }; 11 | 12 | if (cluster.isWorker) 13 | { 14 | bunyanConfig.clusterWorkerId = cluster.worker.id; 15 | } 16 | 17 | if (config.isProductionEnv) 18 | { 19 | bunyanConfig.level = 'info'; 20 | // bunyanConfig.streams = [ 21 | // { 22 | // type: 'raw', 23 | // stream: createCWStream({ 24 | // logGroupName: config.logGroupName, 25 | // logStreamName: config.logStreamName, 26 | // cloudWatchLogsOptions: { 27 | // region: config.region, 28 | // }, 29 | // }), 30 | // }, 31 | // ]; 32 | } 33 | else if (config.isTestEnv) 34 | { 35 | bunyanConfig.level = 'fatal'; 36 | } 37 | 38 | export const logger = bunyan.createLogger(bunyanConfig); 39 | -------------------------------------------------------------------------------- /client/html/favicons/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | Created by potrace 1.11, written by Peter Selinger 2001-2013 9 | 10 | 12 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2017 Chad Engler 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /server/src/migrations/01_ExternalJs.ts: -------------------------------------------------------------------------------- 1 | import { QueryInterface, DataTypes } from 'sequelize'; 2 | import { MigrationParams } from 'umzug'; 3 | 4 | export async function up({ context: queryInterface }: MigrationParams) 5 | { 6 | await queryInterface.createTable('external_js', { 7 | id: { 8 | type: DataTypes.INTEGER, 9 | primaryKey: true, 10 | autoIncrement: true, 11 | }, 12 | url: { 13 | type: DataTypes.STRING(1023), 14 | allowNull: false, 15 | }, 16 | playgroundId: { 17 | type: DataTypes.INTEGER, 18 | references: { 19 | model: 'playgrounds', 20 | key: 'id', 21 | }, 22 | onDelete: 'cascade', 23 | onUpdate: 'cascade', 24 | }, 25 | createdAt: { 26 | type: DataTypes.DATE, 27 | }, 28 | updatedAt: { 29 | type: DataTypes.DATE, 30 | }, 31 | lockVersion: { 32 | type: DataTypes.INTEGER, 33 | }, 34 | }); 35 | } 36 | 37 | export async function down({ context: queryInterface }: MigrationParams) 38 | { 39 | await queryInterface.dropTable('external_js'); 40 | } 41 | -------------------------------------------------------------------------------- /server/src/server.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Main application bootstrap, creates the necessary objects and provides 3 | * a `.start()` method to kick off the server. 4 | */ 5 | import { promisify } from 'util'; 6 | import { setupMiddleware } from './middleware'; 7 | import { setupRoutes } from './routes/index'; 8 | import { setupModels } from './models/index'; 9 | import { logger } from './lib/logger'; 10 | import { db } from './lib/db'; 11 | import * as restify from 'restify'; 12 | import * as config from './config'; 13 | 14 | let app: restify.Server = null; 15 | 16 | export async function start(): Promise 17 | { 18 | app = restify.createServer({ log: logger, name: 'playground-server' }); 19 | 20 | // Initialize 21 | setupMiddleware(app); 22 | setupRoutes(app); 23 | setupModels(db); 24 | 25 | // Start listening for connections 26 | await db.authenticate(); 27 | logger.info('Connected to DB.'); 28 | 29 | await promisify(app.listen).call(app, config.port, config.host); 30 | logger.info('%s listening to: %s', app.name, app.url); 31 | 32 | return app; 33 | } 34 | 35 | export function close(): Promise 36 | { 37 | if (!app) 38 | return Promise.resolve(); 39 | 40 | return new Promise((resolve, reject) => 41 | { 42 | app.close(() => resolve()); 43 | }).then(() => db.close()); 44 | } 45 | -------------------------------------------------------------------------------- /client/less/button.less: -------------------------------------------------------------------------------- 1 | @keyframes glow { 2 | 0% { 3 | border-color: #c11953; 4 | box-shadow: 0 0 5px rgba(193,25,83,.2), inset 0 0 0 rgba(255,255,255,0.2); 5 | } 6 | 100% { 7 | border-color: #8e113c; 8 | box-shadow: 0 0 20px rgba(142,17,60,.6), inset 0 0 0 1px rgba(255,255,255,0.8); 9 | } 10 | } 11 | 12 | .btn { 13 | font-family: 'Roboto', Helvetica, sans-serif; 14 | border: 1px solid #c11953; 15 | background-color: #e72266; 16 | color: #fdd; 17 | padding:6px 15px; 18 | letter-spacing: 0.1em; 19 | text-transform: uppercase; 20 | font-size:10px; 21 | cursor:pointer; 22 | border-radius: 2px; 23 | margin:0 2px; 24 | outline:none; 25 | transition:all 0.2s; 26 | text-shadow: 0 1px 1px rgba(0, 0, 0, 0.3); 27 | &:hover { 28 | color:#fff; 29 | box-shadow: 0 0 0 1px rgba(255,255,255,0.8) inset; 30 | border-color:#8e113c; 31 | } 32 | &:active { 33 | box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.1) inset; 34 | background-color:#c71b56; 35 | // padding:7px 15px 5px; 36 | } 37 | &.glow { 38 | animation: glow 1s ease-out infinite alternate; 39 | color: #fff; 40 | } 41 | .fa, .far, .fas { 42 | margin-right:0.5em; 43 | } 44 | .loading { 45 | .spinner(1em, 1px); 46 | margin-left:0.5em; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /server/pixiplayground.com.conf: -------------------------------------------------------------------------------- 1 | # Nginx config for pixiplayground 2 | server { 3 | listen 80; 4 | listen [::]:80; 5 | 6 | listen [::]:443 ssl http2 ipv6only=on; 7 | listen 443 ssl http2; 8 | 9 | root /var/www/pixiplayground.com/html; 10 | index index.html index.htm; 11 | 12 | server_name pixiplayground.com www.pixiplayground.com; 13 | 14 | gzip on; 15 | gzip_comp_level 4; 16 | gzip_types text/plain text/css application/json application/x-javascript application/javascript text/xml application/xml application/xml+rss text/javascript; 17 | 18 | location ~* \.(?:manifest|appcache|html?)$ { 19 | expires 2h; 20 | add_header Cache-Control "public, no-transform"; 21 | try_files $uri =404; 22 | } 23 | 24 | location / { 25 | expires 5d; 26 | add_header Cache-Control "public, no-transform"; 27 | try_files $uri $uri/ =404; 28 | } 29 | 30 | location /api { 31 | proxy_pass http://localhost:3000; 32 | proxy_http_version 1.1; 33 | proxy_set_header Upgrade $http_upgrade; 34 | proxy_set_header Connection 'upgrade'; 35 | proxy_set_header Host $host; 36 | proxy_set_header X-Real-IP $remote_addr; 37 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 38 | proxy_cache_bypass $http_upgrade; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /server/src/config.ts: -------------------------------------------------------------------------------- 1 | import { config } from 'dotenv'; 2 | 3 | config(); 4 | 5 | import * as corsMiddleware from 'restify-cors-middleware'; 6 | import { SequelizeOptions } from 'sequelize-typescript'; 7 | 8 | export const env = process.env.NODE_ENV || 'development'; 9 | 10 | export const isProductionEnv = env === 'production'; 11 | export const isTestEnv = env === 'test'; 12 | 13 | export const port = process.env.PORT || 3000; 14 | export const host = process.env.HOST || '127.0.0.1'; 15 | 16 | export const cloudflare = { 17 | zoneId: process.env.CF_ZONE_ID || '', 18 | authName: process.env.CF_AUTH_NAME || '', 19 | authKey: process.env.CF_AUTH_KEY || '', 20 | }; 21 | 22 | export const cors: corsMiddleware.Options = { 23 | origins: isProductionEnv ? ['*.pixiplayground.com', 'pixijs.io'] : ['*'], 24 | allowHeaders: [], 25 | exposeHeaders: [], 26 | }; 27 | 28 | export const db: SequelizeOptions = { 29 | host: process.env.DB_HOSTNAME || 'localhost', 30 | port: process.env.DB_PORT ? parseInt(process.env.DB_PORT, 10) : 3306, 31 | username: process.env.DB_USERNAME || 'playground', 32 | password: process.env.DB_PASSWORD || '', 33 | database: process.env.DB_NAME || 'pixi_playground', 34 | dialect: isProductionEnv ? 'mysql' : 'sqlite', 35 | storage: isTestEnv ? ':memory:' : 'data.sqlite', 36 | define: { 37 | timestamps: true, 38 | engine: isProductionEnv ? 'InnoDB' : '', 39 | version: 'lockVersion', 40 | }, 41 | benchmark: !isProductionEnv, 42 | }; 43 | -------------------------------------------------------------------------------- /client/html/results.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | <%- htmlWebpackPlugin.options.title %> 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 36 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /server/src/app.ts: -------------------------------------------------------------------------------- 1 | import { logger } from './lib/logger'; 2 | import * as server from './server'; 3 | 4 | // Starts the server and notifies PM2 we are online 5 | async function startup() 6 | { 7 | await server.start(); 8 | 9 | if (process.send) 10 | process.send('ready'); 11 | 12 | logger.info('Application is up and ready.'); 13 | } 14 | 15 | // Shuts down the server and exits 16 | let isShuttingDown = false; 17 | async function shutdown() 18 | { 19 | if (isShuttingDown) 20 | return; 21 | 22 | isShuttingDown = true; 23 | logger.info('Application performing graceful shutdown.'); 24 | 25 | // Stops the server from accepting new connections and finishes existing connections. 26 | try 27 | { 28 | await server.close(); 29 | logger.info('Application graceful shutdown complete.'); 30 | } 31 | catch (err) 32 | { 33 | logger.error({ err }, 'Application graceful shutdown failed, stopping process.'); 34 | process.exit(1); 35 | } 36 | } 37 | 38 | // MAIN 39 | (async () => 40 | { 41 | process.on('SIGINT', async () => 42 | { 43 | logger.info('SIGINT signal received.'); 44 | await shutdown(); 45 | }); 46 | 47 | process.on('message', async (msg) => 48 | { 49 | if (msg == 'shutdown') 50 | { 51 | logger.info('Shutdown message received.'); 52 | await shutdown(); 53 | } 54 | }); 55 | 56 | try 57 | { 58 | await startup(); 59 | } 60 | catch (err) 61 | { 62 | logger.error({ err }, 'Application startup failed, stopping process.'); 63 | process.exit(1); 64 | } 65 | })(); 66 | -------------------------------------------------------------------------------- /client/less/toggle.less: -------------------------------------------------------------------------------- 1 | /* The switch - the box around the slider */ 2 | @switch-height : 27px; 3 | @swith-width : 50px; 4 | @padding: 4px; 5 | @knob-size: @switch-height - @padding * 2; 6 | @knob-bg-color: #e72266; 7 | @knob-bg-active: #22e740; 8 | @body-color:rgba(#EEEEEE, 0.5); 9 | 10 | .switch { 11 | position: relative; 12 | display: inline-block; 13 | width: @swith-width; 14 | height: @switch-height; 15 | 16 | 17 | /* Hide default HTML checkbox */ 18 | input { 19 | opacity: 0; 20 | width: 0; 21 | height: 0; 22 | } 23 | 24 | /* The slider */ 25 | .slider { 26 | position: absolute; 27 | cursor: pointer; 28 | top: 0; 29 | left: 0; 30 | right: 0; 31 | bottom: 0; 32 | background-color: @body-color; 33 | -webkit-transition: .4s; 34 | transition: .4s; 35 | box-shadow: 0 0 0 1px #8e113c; 36 | 37 | &:before { 38 | position: absolute; 39 | content: ""; 40 | height: @knob-size; 41 | width: @knob-size; 42 | left: (@switch-height - @knob-size) / 2; 43 | bottom: (@switch-height - @knob-size) / 2; 44 | background-color: @knob-bg-color; 45 | -webkit-transition: .4s; 46 | transition: .4s; 47 | } 48 | } 49 | 50 | input:checked + .slider:before { 51 | background-color: @knob-bg-active; 52 | transform: translateX( @swith-width - @knob-size - @padding * 2); 53 | } 54 | 55 | /* Rounded sliders */ 56 | .round { 57 | border-radius: @switch-height / 2; 58 | } 59 | 60 | .round:before { 61 | border-radius: 50%; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /client/less/alert.less: -------------------------------------------------------------------------------- 1 | @alert-height: 40px; 2 | @alert-hide-top: -(@alert-height + 5px); 3 | @alert-hide-opacity: 0; 4 | @alert-show-top: 0; 5 | @alert-show-opacity: 1; 6 | 7 | @keyframes alert { 8 | 0% { top: @alert-hide-top; opacity: @alert-hide-opacity; } 9 | 100% { top: @alert-show-top; opacity: @alert-show-opacity; } 10 | } 11 | 12 | #alert { 13 | position: relative; 14 | z-index: 200; 15 | 16 | > span { 17 | background-image: linear-gradient(135deg, transparent, 18 | transparent 25%, hsla(0,0%,0%,.1) 25%, 19 | hsla(0,0%,0%,.1) 50%, transparent 50%, 20 | transparent 75%, hsla(0,0%,0%,.1) 75%, 21 | hsla(0,0%,0%,.1)); 22 | background-size: (@alert-height / 2) (@alert-height / 2); 23 | display: block; 24 | font: bold 16px/@alert-height sans-serif; 25 | height: @alert-height; 26 | position: absolute; 27 | text-align: center; 28 | text-decoration: none; 29 | width: 100%; 30 | 31 | top: @alert-hide-top; 32 | opacity: @alert-hide-opacity; 33 | transition: 0.6s all; 34 | 35 | &.success { 36 | color: #f6f6f6; 37 | background-color: #3cc453; 38 | } 39 | 40 | &.info { 41 | color: #f6f6f6; 42 | background-color: #3c4ac4; 43 | } 44 | 45 | &.warning { 46 | color: #f6f6f6; 47 | background-color: #c4b03c; 48 | } 49 | 50 | &.error { 51 | color: #f6f6f6; 52 | background-color: #c4453c; 53 | } 54 | 55 | &.shown { 56 | top: @alert-show-top; 57 | opacity: @alert-show-opacity; 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@pixi/playground-server", 3 | "version": "1.6.3", 4 | "description": "Server application for the pixi playground.", 5 | "author": "Chad Engler ", 6 | "license": "MIT", 7 | "private": true, 8 | "homepage": "https://github.com/englercj/playground#readme", 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/englercj/playground.git" 12 | }, 13 | "bugs": { 14 | "url": "https://github.com/englercj/playground/issues" 15 | }, 16 | "scripts": { 17 | "build": "npm run clean && tsc -p tsconfig.json --outDir dist", 18 | "clean": "rimraf dist", 19 | "dev": "ts-node src/migrate.ts | bunyan && nodemon | bunyan", 20 | "test": "vitest run" 21 | }, 22 | "devDependencies": { 23 | "@types/bunyan": "^1.8.11", 24 | "@types/http-codes": "^1.0.4", 25 | "@types/nanoid": "^3.0.0", 26 | "@types/node": "^20.11.25", 27 | "@types/restify": "^8.5.12", 28 | "@types/restify-cors-middleware": "^1.0.5", 29 | "@types/restify-errors": "^4.3.9", 30 | "@types/supertest": "^6.0.2", 31 | "nodemon": "^3.1.0", 32 | "rimraf": "^5.0.5", 33 | "sqlite3": "^5.1.7", 34 | "supertest": "^6.3.4", 35 | "ts-node": "^10.9.2", 36 | "tslint": "^6.1.3", 37 | "typescript": "^5.4.2", 38 | "vite": "^5.1.5", 39 | "vitest": "^1.3.1" 40 | }, 41 | "dependencies": { 42 | "bunyan": "^1.8.15", 43 | "dotenv": "^16.4.5", 44 | "http-codes": "^1.0.0", 45 | "mysql2": "^3.9.2", 46 | "nanoid": "^3.3.7", 47 | "reflect-metadata": "^0.2.1", 48 | "restify": "^11.1.0", 49 | "restify-cors-middleware": "^1.1.1", 50 | "restify-errors": "^8.0.2", 51 | "sequelize": "^6.37.1", 52 | "sequelize-typescript": "^2.1.6", 53 | "umzug": "^3.7.0" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PixiJS Playground [![Build Status](https://travis-ci.org/englercj/playground.svg?branch=master)](https://travis-ci.org/englercj/playground) 2 | 3 | The playground application is a server and client that allow you to create, view, save, 4 | and share small PixiJS demos. 5 | 6 | - Client code is in the [client folder][cf]. 7 | - Server code is in the [server folder][sf]. 8 | 9 | ## Usage: 10 | 11 | To run the application locally open two terminals, one to the [client folder][cf] and one to the [server folder][sf]. 12 | 13 | In each of them run `npm install` to install their individual dependencies. Then run `npm run dev` for each to start 14 | locally. Finally, visit http://localhost:8080. Details can be found in the respective READMEs. 15 | 16 | [cf]: client/ 17 | [sf]: server/ 18 | 19 | ## To Do: 20 | 21 | ### Should Have (v1 or v2) 22 | 23 | 1. Load all scripts at once in results, then only execute in order. 24 | 2. Data attachments like images, or json to power a demo. 25 | 3. Embed view that embeds just the preview with a link back 26 | * Useful for blog/forums posts and such. 27 | 4. UI to star a playground 28 | 5. Add homepage and search results 29 | * Show highly starred/trending playgrounds on homepage 30 | * Also use official/features flags for homepage 31 | 32 | ### Nice to Have (v2+) 33 | 34 | 1. Infinite loop detection (https://github.com/CodePen/InfiniteLoopBuster) 35 | 2. Add some snippets for monaco, and enable command palette 36 | 3. Different default demos for different versions 37 | 4. More editor settings (tabs, theme, etc) 38 | 5. Add github auth integration for login 39 | * List your own playgrounds 40 | * Consistent author field 41 | * Import/Export from/to Gist functionality 42 | 6. Multi-file support, as well as custom html/css 43 | 7. Move logic/state out of views and use a pattern (reflux/redux, or something) 44 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@pixi/playground-client", 3 | "version": "1.6.3", 4 | "description": "Playground for exploring pixi code.", 5 | "author": "Chad Engler ", 6 | "license": "MIT", 7 | "private": true, 8 | "homepage": "https://github.com/englercj/playground#readme", 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/englercj/playground.git" 12 | }, 13 | "bugs": { 14 | "url": "https://github.com/englercj/playground/issues" 15 | }, 16 | "scripts": { 17 | "build": "npm run clean && webpack --progress", 18 | "clean": "rimraf dist", 19 | "dev": "webpack-dev-server", 20 | "test": "echo 'No client tests yet!'" 21 | }, 22 | "devDependencies": { 23 | "@types/history": "^4.7.11", 24 | "autoprefixer": "^10.4.18", 25 | "copy-webpack-plugin": "^12.0.2", 26 | "css-loader": "^6.10.0", 27 | "cssnano": "^6.1.0", 28 | "file-loader": "^6.2.0", 29 | "html-webpack-plugin": "^5.6.0", 30 | "image-webpack-loader": "^8.1.0", 31 | "less": "^4.2.0", 32 | "less-loader": "^12.2.0", 33 | "mini-css-extract-plugin": "^2.8.1", 34 | "monaco-editor-webpack-plugin": "^7.1.0", 35 | "rimraf": "^5.0.5", 36 | "style-loader": "^3.3.4", 37 | "ts-loader": "^9.5.1", 38 | "tslint": "^6.1.3", 39 | "typescript": "^5.4.2", 40 | "webpack": "^5.90.3", 41 | "webpack-cli": "^5.1.4", 42 | "webpack-dev-server": "^5.0.2", 43 | "webpack-stats-plugin": "^1.1.3" 44 | }, 45 | "dependencies": { 46 | "ajv": "^8.12.0", 47 | "decko": "^1.2.0", 48 | "history": "^4.10.1", 49 | "linkstate": "^2.0.1", 50 | "mini-signals": "^2.0.0", 51 | "monaco-editor": "^0.46.0", 52 | "normalize.css": "^8.0.1", 53 | "preact": "^10.19.6", 54 | "preact-render-to-string": "^6.4.0", 55 | "preact-router": "^4.1.2" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /server/droplet-setup.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | $DOMAIN_NAME="pixiplayground.com" 4 | 5 | # Create 'deploy' user that will deploy and run the app 6 | useradd -s /bin/bash -m -d /home/deploy -c "deploy" deploy 7 | # Upload `~/.ssh/pixi_playground_deploy_rsa.pub` as an authorized key 8 | 9 | # Install Nginx 10 | apt update 11 | apt install unzip nginx 12 | 13 | # Setup firewall rules 14 | ufw allow OpenSSH 15 | ufw allow 'Nginx Full' 16 | 17 | # Setup nginx site 18 | mkdir -p /var/www/$DOMAIN_NAME/html 19 | chown -R deploy:deploy /var/www/$DOMAIN_NAME 20 | chmod 755 /var/www/$DOMAIN_NAME 21 | touch /etc/nginx/sites-available/$DOMAIN_NAME 22 | chown deploy:deploy /etc/nginx/sites-available/$DOMAIN_NAME 23 | chmod 644 /etc/nginx/sites-available/$DOMAIN_NAME 24 | # Copy contents of `server/pixiplayground.com.conf` into `/etc/nginx/sites-available/$DOMAIN_NAME` 25 | ln -s /etc/nginx/sites-available/$DOMAIN_NAME /etc/nginx/sites-enabled/ 26 | nginx -t 27 | systemctl restart nginx 28 | 29 | # Install Certbot for SSL Cert 30 | add-apt-repository ppa:certbot/certbot 31 | apt install python3-pip python-certbot-nginx 32 | pip install certbot-dns-cloudflare 33 | 34 | # Run certbot to install the certificate 35 | certbot --nginx -d $DOMAIN_NAME -d www.$DOMAIN_NAME 36 | nginx -t 37 | systemctl restart nginx 38 | 39 | # Install Node v20.x and setup pm2 40 | curl -sL https://deb.nodesource.com/setup_20.x -o /tmp/nodesource_setup.sh 41 | bash /tmp/nodesource_setup.sh 42 | apt install nodejs build-essential 43 | npm install pm2@latest -g 44 | pm2 startup systemd -u deploy --hp /home/deploy 45 | 46 | # Once the server is setup in cloudflare, you will need to edit /etc/cron.d/certbot to use 47 | # the cloudflare DNS plugin. The command should look like: 48 | certbot renew --dns-cloudflare --dns-cloudflare-credentials /root/.secrets/certbot/cloudflare.ini 49 | # The secrets file should be created that contains the creds. All permissions should be 700 or 600. 50 | -------------------------------------------------------------------------------- /client/src/components/Radio.tsx: -------------------------------------------------------------------------------- 1 | import { h, Component } from 'preact'; 2 | import { JSXInternal } from 'preact/src/jsx'; 3 | 4 | interface IRadioProps extends JSXInternal.HTMLAttributes 5 | { 6 | } 7 | 8 | export class Radio extends Component 9 | { 10 | render(props: IRadioProps) 11 | { 12 | const { name, selectedValue, onChange } = this.context.radioGroup; 13 | const optional: any = {}; 14 | 15 | if (selectedValue !== undefined) 16 | { 17 | optional.checked = (props.value === selectedValue); 18 | } 19 | 20 | if (typeof onChange === 'function') 21 | { 22 | optional.onChange = onChange.bind(null, props.value); 23 | } 24 | 25 | if (props.className) 26 | { 27 | props.className += " radio-btn"; 28 | } 29 | else 30 | { 31 | props.className = "radio-btn"; 32 | } 33 | 34 | return 41 | } 42 | } 43 | 44 | interface IRadioGroupProps 45 | { 46 | name?: string; 47 | selectedValue?: string|number|boolean; 48 | onChange?: (value: string) => void; 49 | Component?: JSXInternal.ElementType; 50 | } 51 | 52 | export class RadioGroup extends Component 53 | { 54 | static defaultProps: IRadioGroupProps = { 55 | Component: 'div', 56 | }; 57 | 58 | getChildContext() 59 | { 60 | const { name, selectedValue, onChange } = this.props; 61 | return { 62 | radioGroup: { 63 | name, selectedValue, onChange, 64 | }, 65 | }; 66 | } 67 | 68 | render() 69 | { 70 | const { Component, name, selectedValue, onChange, children, ...rest } = this.props; 71 | return {children}; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /client/src/service/http.ts: -------------------------------------------------------------------------------- 1 | export type THttpCallback = (err: Error, data?: T) => void; 2 | 3 | function sendRequest(method: string, url: string, data: any, callback: THttpCallback) 4 | { 5 | const xhr = new XMLHttpRequest(); 6 | 7 | xhr.open(method, url, true); 8 | 9 | xhr.addEventListener('error', () => 10 | { 11 | callback(new Error(`Request failed. Status: ${xhr.status}, text: "${xhr.statusText}"`)); 12 | }, false); 13 | 14 | xhr.addEventListener('load', () => 15 | { 16 | let res; 17 | 18 | try 19 | { 20 | res = JSON.parse(xhr.responseText); 21 | } 22 | catch (e) 23 | { 24 | res = xhr.responseText; 25 | } 26 | 27 | let err = null; 28 | 29 | // if non-2XX code 30 | if (Math.floor(xhr.status / 100) !== 2) 31 | { 32 | let msg = xhr.statusText; 33 | 34 | if (typeof res === 'string') 35 | msg = res; 36 | else if (typeof res.msg === 'string') 37 | msg = res.msg; 38 | 39 | err = new Error(msg); 40 | } 41 | 42 | callback(err, res); 43 | }, false); 44 | 45 | xhr.responseType = 'text'; 46 | 47 | if (method !== 'GET' && data) 48 | { 49 | xhr.setRequestHeader('Content-Type', 'application/json'); 50 | xhr.send(JSON.stringify(data)); 51 | } 52 | else 53 | { 54 | xhr.send(); 55 | } 56 | 57 | return xhr; 58 | } 59 | 60 | export function get(url: string, callback: THttpCallback) 61 | { 62 | return sendRequest('GET', url, null, callback); 63 | } 64 | 65 | export function post(url: string, data: any, callback: THttpCallback) 66 | { 67 | return sendRequest('POST', url, data, callback); 68 | } 69 | 70 | export function put(url: string, data: any, callback: THttpCallback) 71 | { 72 | return sendRequest('PUT', url, data, callback); 73 | } 74 | 75 | export function del(url: string, callback: THttpCallback) 76 | { 77 | return sendRequest('DELETE', url, null, callback); 78 | } 79 | -------------------------------------------------------------------------------- /server/src/middleware.ts: -------------------------------------------------------------------------------- 1 | import * as restify from 'restify'; 2 | import * as http from 'http'; 3 | import * as config from './config'; 4 | import corsMiddleware from 'restify-cors-middleware'; 5 | import { HttpError } from 'restify-errors'; 6 | 7 | export function setupMiddleware(app: restify.Server) 8 | { 9 | const cors = corsMiddleware(config.cors); 10 | 11 | app.pre(cors.preflight); 12 | 13 | app.pre(restify.pre.sanitizePath()); 14 | 15 | app.use(cors.actual); 16 | 17 | app.use(restify.plugins.requestLogger()); 18 | 19 | app.use(restify.plugins.queryParser({ 20 | mapParams: true, 21 | })); 22 | 23 | app.use(restify.plugins.jsonBodyParser({ 24 | maxBodySize: 15 * 1024 * 1024, // 15 MB, we use mediumtext for MySQL which is ~16MB max 25 | mapParams: true, 26 | overrideParams: false, 27 | } as any)); 28 | 29 | app.use(restify.plugins.throttle({ 30 | burst: 100, 31 | rate: 50, 32 | ip: true, 33 | })); 34 | 35 | app.on('restifyError', (req: restify.Request, res: restify.Response, err: Error, callback: () => void) => 36 | { 37 | req.log.error(err, { url: req.url, params: req.params, query: req.query }); 38 | 39 | // Ensure a 500 and generic message is sent to the client for any non-http errors. 40 | if (!(err instanceof HttpError)) 41 | { 42 | res.statusCode = 500; 43 | 44 | const message = 'An unexpected error occurred'; 45 | (err as any).toJSON = () => { err.name, message }; 46 | (err as any).toString = () => message; 47 | } 48 | 49 | callback(); 50 | }); 51 | }; 52 | 53 | const Response = (http as any).ServerResponse; 54 | 55 | // Add a links property to the response object 56 | Response.prototype.links = function linkHeaderFormatter(links: { [key: string]: string }) 57 | { 58 | let link = this.getHeader('Link') || ''; 59 | 60 | if (link) 61 | link += ', '; 62 | 63 | const linksStr = Object.keys(links) 64 | .map((rel) => `<${links[rel]}>; rel="${rel}"`) 65 | .join(', '); 66 | 67 | return this.setHeader('Link', link + linksStr); 68 | }; 69 | -------------------------------------------------------------------------------- /client/src/pages/Search.tsx: -------------------------------------------------------------------------------- 1 | import { h, Component, ComponentChild } from 'preact'; 2 | import { getQueryParam } from '../util/queryUtils'; 3 | import { searchPlaygrounds } from '../service'; 4 | import { TopBar } from '../components/TopBar'; 5 | import { IPageProps } from './IPageProps'; 6 | import { IPlayground } from '../../../shared/types'; 7 | 8 | interface IState 9 | { 10 | loading: boolean; 11 | results: IPlayground[]; 12 | error: Error; 13 | } 14 | 15 | export class Search extends Component 16 | { 17 | constructor(props: IPageProps, context: any) 18 | { 19 | super(props, context); 20 | 21 | this.state = { 22 | loading: true, 23 | results: null, 24 | error: null, 25 | }; 26 | } 27 | 28 | componentWillMount() 29 | { 30 | const searchStr = getQueryParam('q'); 31 | 32 | if (searchStr) 33 | { 34 | searchPlaygrounds(searchStr, (err, data) => 35 | { 36 | if (err) 37 | { 38 | this.setState({ loading: false, results: null, error: err }); 39 | } 40 | else 41 | { 42 | this.setState({ loading: false, results: data, error: null }); 43 | } 44 | }); 45 | } 46 | else 47 | { 48 | this.setState({ loading: false, error: new Error('No search query!') }); 49 | } 50 | } 51 | 52 | render() 53 | { 54 | let view: ComponentChild; 55 | 56 | if (this.state.loading) 57 | { 58 | view = Loading...; 59 | } 60 | else if (this.state.error) 61 | { 62 | view =
{this.state.error.stack}
; 63 | } 64 | else 65 | { 66 | view =
{JSON.stringify(this.state.results, null, 4)}
; 67 | } 68 | 69 | return ( 70 |
71 | 72 |
73 | {view} 74 |
75 | ); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /client/src/results.ts: -------------------------------------------------------------------------------- 1 | import { IPlayground } from '../../shared/types'; 2 | import { PixiVersionType, getPixiVersionType } from './util/pixiVersionType'; 3 | 4 | const validOrigins = [ 5 | 'https://www.pixiplayground.com', 6 | 'https://pixiplayground.com', 7 | 'http://localhost:8080', 8 | ]; 9 | 10 | let windowLoaded: boolean = false; 11 | let queuedData: IPlayground = null; 12 | 13 | window.addEventListener('load', handleLoad, false); 14 | window.addEventListener('message', handleMessage, false); 15 | 16 | function handleLoad() 17 | { 18 | windowLoaded = true; 19 | 20 | if (queuedData) 21 | { 22 | updateDemo(queuedData); 23 | queuedData = null; 24 | } 25 | } 26 | 27 | function handleMessage(event: MessageEvent) 28 | { 29 | // Ensure this is a trusted domain 30 | if (validOrigins.indexOf(event.origin) === -1) 31 | return; 32 | 33 | const data: IPlayground = event.data; 34 | 35 | if (!data || !data.contents) 36 | return; 37 | 38 | if (windowLoaded) 39 | updateDemo(data); 40 | else 41 | queuedData = data; 42 | } 43 | 44 | function updateDemo(data: IPlayground) 45 | { 46 | updateScripts(data, () => updateDemoCode(data)); 47 | } 48 | 49 | function updateScripts(data: IPlayground, cb: () => void) 50 | { 51 | let scripts = []; 52 | 53 | // Add pixi version 54 | const versionType = getPixiVersionType(data.pixiVersion); 55 | let pixiUrl = ''; 56 | 57 | if (versionType === PixiVersionType.Release || versionType === PixiVersionType.Tag) 58 | pixiUrl = `https://d157l7jdn8e5sf.cloudfront.net/${data.pixiVersion || 'release'}/pixi.js`; 59 | else 60 | pixiUrl = data.pixiVersion; 61 | 62 | scripts.push(pixiUrl); 63 | 64 | // Add external scripts 65 | if (data.externaljs && data.externaljs.length > 0) 66 | scripts = scripts.concat(data.externaljs.map((v) => v.url)); 67 | 68 | // load each in series 69 | eachSeries(scripts, loadScript, cb); 70 | } 71 | 72 | function updateDemoCode(data: IPlayground) 73 | { 74 | const script = document.createElement('script'); 75 | script.textContent = `${data.contents}\n//# sourceURL=demo.js\n`; 76 | 77 | document.body.appendChild(script); 78 | } 79 | 80 | function loadScript(url: string, cb: () => void) 81 | { 82 | const script = document.createElement('script'); 83 | script.src = url; 84 | script.onload = cb; 85 | script.onerror = cb; 86 | 87 | document.body.appendChild(script); 88 | } 89 | 90 | type TNextCallback = () => void; 91 | type TIterator = (value: T, next: TNextCallback) => void; 92 | function eachSeries(array: T[], iter: TIterator, done: TNextCallback) 93 | { 94 | let index = 0; 95 | const next = () => 96 | { 97 | if (index === array.length) 98 | return done(); 99 | 100 | iter(array[index++], next); 101 | }; 102 | 103 | next(); 104 | } 105 | -------------------------------------------------------------------------------- /client/src/components/EditorTopBar.tsx: -------------------------------------------------------------------------------- 1 | import { h, Component } from 'preact'; 2 | import { bind } from 'decko'; 3 | 4 | interface IProps 5 | { 6 | name: string; 7 | saving: boolean; 8 | dirty: boolean; 9 | showClone: boolean; 10 | onUpdateClick?: () => void; 11 | onSettingsClick?: () => void; 12 | onCloneClick?: () => void; 13 | onSaveClick?: () => void; 14 | } 15 | interface IState { 16 | isEditorMode: boolean; 17 | } 18 | 19 | export class EditorTopBar extends Component 20 | { 21 | render(props: IProps) 22 | { 23 | return ( 24 | 56 | ); 57 | } 58 | 59 | @bind 60 | private _onUpdateClick() 61 | { 62 | if (this.props.onUpdateClick) 63 | this.props.onUpdateClick(); 64 | } 65 | 66 | @bind 67 | private _onSettingsClick() 68 | { 69 | if (this.props.onSettingsClick) 70 | this.props.onSettingsClick(); 71 | } 72 | 73 | @bind 74 | private _onCloneClick() 75 | { 76 | if (this.props.onCloneClick) 77 | this.props.onCloneClick(); 78 | } 79 | 80 | @bind 81 | private _onSaveClick() 82 | { 83 | if (this.props.onSaveClick) 84 | this.props.onSaveClick(); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or advances 26 | * Trolling, insulting/derogatory comments, and personal or political attacks 27 | * Public or private harassment 28 | * Publishing others' private information, such as a physical or electronic address, without explicit permission 29 | * Other conduct which could reasonably be considered inappropriate in a professional setting 30 | 31 | ## Our Responsibilities 32 | 33 | Project maintainers are responsible for clarifying the standards of acceptable 34 | behavior and are expected to take appropriate and fair corrective action in 35 | response to any instances of unacceptable behavior. 36 | 37 | Project maintainers have the right and responsibility to remove, edit, or 38 | reject comments, commits, code, Wiki edits, issues, and other contributions 39 | that are not aligned to this Code of Conduct, or to ban temporarily or 40 | permanently any contributor for other behaviors that they deem inappropriate, 41 | threatening, offensive, or harmful. 42 | 43 | ## Scope 44 | 45 | This Code of Conduct applies both within project spaces and in public spaces 46 | when an individual is representing the project or its community. Examples of 47 | representing a project or community include using an official project e-mail 48 | address, posting via an official social media account, or acting as an appointed 49 | representative at an online or offline event. Representation of a project may be 50 | further defined and clarified by project maintainers. 51 | 52 | ## Enforcement 53 | 54 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 55 | reported by contacting the project team at chad@pantherdev.com. All 56 | complaints will be reviewed and investigated and will result in a response that 57 | is deemed necessary and appropriate to the circumstances. The project team is 58 | obligated to maintain confidentiality with regard to the reporter of an incident. 59 | Further details of specific enforcement policies may be posted separately. 60 | 61 | Project maintainers who do not follow or enforce the Code of Conduct in good 62 | faith may face temporary or permanent repercussions as determined by other 63 | members of the project's leadership. 64 | 65 | ## Attribution 66 | 67 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 68 | available at [http://contributor-covenant.org/version/1/4][version] 69 | 70 | [homepage]: http://contributor-covenant.org 71 | [version]: http://contributor-covenant.org/version/1/4/ 72 | -------------------------------------------------------------------------------- /client/html/index.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | <%- htmlWebpackPlugin.options.title %> 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 53 | 54 | 55 | 56 | 57 | 58 | 59 |
60 | 64 | 65 | 66 | -------------------------------------------------------------------------------- /client/src/service/index.ts: -------------------------------------------------------------------------------- 1 | import { IPlayground } from '../../../shared/types'; 2 | import { get, put, post, THttpCallback } from './http'; 3 | import { Storage } from '../util/Storage'; 4 | import { getPixiVersionType, PixiVersionType } from '../util/pixiVersionType'; 5 | 6 | // @ts-ignore 7 | let baseOrigin = __BASE_ORIGIN__; 8 | 9 | if (typeof localStorage !== 'undefined') { 10 | const apiOriginOverride = localStorage.getItem('apiOriginOverride'); 11 | 12 | if (apiOriginOverride) 13 | { 14 | baseOrigin = apiOriginOverride; 15 | } 16 | } 17 | 18 | export function searchPlaygrounds(searchStr: string, cb: THttpCallback) 19 | { 20 | get(`${baseOrigin}/api/playgrounds?q=${encodeURIComponent(searchStr)}`, cb); 21 | } 22 | 23 | export function getPlayground(slug: string, cb: THttpCallback) 24 | { 25 | get(`${baseOrigin}/api/playground/${slug}`, cb); 26 | } 27 | 28 | export function createPlayground(data: IPlayground, cb: THttpCallback) 29 | { 30 | post(`${baseOrigin}/api/playground`, data, cb); 31 | } 32 | 33 | export function updatePlayground(data: IPlayground, cb: THttpCallback) 34 | { 35 | put(`${baseOrigin}/api/playground/${data.slug}`, data, cb); 36 | } 37 | 38 | export function getReleases(cb: THttpCallback) 39 | { 40 | get(`https://api.github.com/repos/pixijs/pixi.js/tags?per_page=100`, (err, res) => 41 | { 42 | if (err) 43 | return cb(err); 44 | 45 | if (!res || !Array.isArray(res)) 46 | return cb(new Error('Invalid response from server.')); 47 | 48 | const tags: string[] = []; 49 | const preReleases: Record = {}; 50 | 51 | for (let i = 0; i < res.length; ++i) 52 | { 53 | if(res[i].name.includes('-')) 54 | { 55 | const [version] = res[i].name.split('-'); 56 | if (preReleases[version] && preReleases[version] > 5) 57 | { 58 | continue; 59 | } 60 | preReleases[version] = (preReleases[version] || 0) + 1; 61 | } 62 | tags.push(res[i].name); 63 | } 64 | 65 | cb(null, tags); 66 | }); 67 | } 68 | 69 | export function getTypings(version: string, cb: (typings: string) => void) 70 | { 71 | let url = ''; 72 | 73 | if (version.indexOf('v4') === 0) 74 | url = `/definitions/${version}/pixi.d.ts`; 75 | else if (version.indexOf('v5.0.') === 0 && parseInt(version.split('.')[2], 10) < 5) 76 | url = `/definitions/${version}/pixi.js.d.ts`; 77 | else if (version === 'release') 78 | url = `/definitions/release/pixi.js.d.ts`; // Temp, remove when we can use pixijs.download 79 | else 80 | url = `https://pixijs.download/${version}/types/pixi.js.d.ts`; 81 | 82 | const cacheable = getPixiVersionType(version) === PixiVersionType.Tag; 83 | 84 | if (cacheable) 85 | { 86 | const cachedTypings = Storage.get(url); 87 | 88 | if (cachedTypings) 89 | { 90 | setTimeout(() => cb(cachedTypings), 1); 91 | return; 92 | } 93 | } 94 | 95 | get(url, (err, str) => 96 | { 97 | if (!err) 98 | { 99 | if (cacheable) 100 | Storage.set(url, str); 101 | 102 | cb(str); 103 | } 104 | else 105 | { 106 | cb(null); 107 | } 108 | }); 109 | } 110 | -------------------------------------------------------------------------------- /server/src/lib/cloudflare.ts: -------------------------------------------------------------------------------- 1 | import * as https from 'https'; 2 | import * as CODES from 'http-codes'; 3 | import * as bunyan from 'bunyan'; 4 | import { isProductionEnv, cloudflare } from '../config'; 5 | 6 | export function purgeEntireCache(log: bunyan): Promise 7 | { 8 | if (!isProductionEnv) 9 | return Promise.resolve(); 10 | 11 | const postData = JSON.stringify({ purge_everything: true }); 12 | 13 | return requestCachePurge(log, postData); 14 | } 15 | 16 | export function purgeCacheForUrls(log: bunyan, urls: string[]): Promise 17 | { 18 | if (!isProductionEnv) 19 | return Promise.resolve(); 20 | 21 | const postData = JSON.stringify({ files: urls }); 22 | 23 | return requestCachePurge(log, postData); 24 | } 25 | 26 | function requestCachePurge(log: bunyan, postData: any): Promise 27 | { 28 | return new Promise((resolve, reject) => 29 | { 30 | const logState: any = { postData }; 31 | 32 | const cfReq = https.request( 33 | `https://api.cloudflare.com/client/v4/zones/${cloudflare.zoneId}/purge_cache`, 34 | { 35 | method: 'POST', 36 | headers: { 37 | 'Content-Type': 'application/json', 38 | 'Content-Length': postData.length, 39 | 'X-Auth-Email': cloudflare.authName, 40 | 'X-Auth-Key': cloudflare.authKey, 41 | }, 42 | }, 43 | (res) => 44 | { 45 | let resStr = ''; 46 | 47 | res.on('data', (chunk) => resStr += chunk); 48 | res.on('end', () => 49 | { 50 | if (res.statusCode !== CODES.OK) 51 | { 52 | logState.code = res.statusCode; 53 | logState.headers = res.headers; 54 | log.error(logState, `Failed to purge Cloudflare cache.`); 55 | reject(new Error('Got non-200 status code from Cloudflare when trying to purge.')); 56 | return; 57 | } 58 | 59 | try 60 | { 61 | let resBody = JSON.parse(resStr); 62 | 63 | logState.resBody = resBody; 64 | 65 | if (resBody.success) 66 | { 67 | log.info(logState, `Successfully purged Cloudflare cache.`); 68 | resolve(); 69 | } 70 | else 71 | { 72 | log.error(logState, `Failed to purge Cloudflare cache.`); 73 | reject(new Error('Got a success=false response from Cloudflare when trying to purge.')); 74 | } 75 | } 76 | catch (e) 77 | { 78 | logState.err = e; 79 | log.error(logState, `Failed to parse response from Cloudflare API during cache purge.`); 80 | reject(new Error('Failed to parse response from Cloudflare when trying to purge.')); 81 | } 82 | }); 83 | }); 84 | 85 | cfReq.on('error', (err) => 86 | { 87 | logState.err = err; 88 | log.error(logState, `Failed to purge cache..`); 89 | reject(err); 90 | }); 91 | 92 | cfReq.write(postData); 93 | cfReq.end(); 94 | }); 95 | } 96 | -------------------------------------------------------------------------------- /server/test/specs/routes/tags.ts: -------------------------------------------------------------------------------- 1 | import * as CODES from 'http-codes'; 2 | import * as supertest from 'supertest'; 3 | import { beforeAll, expect, suite, test } from 'vitest'; 4 | import { request, clearDb } from '../../fixtures/server'; 5 | import { Tag } from '../../../src/models/Tag'; 6 | import { ITag } from '../../../../shared/types'; 7 | 8 | const testTagData: ITag = { 9 | name: 'test', 10 | }; 11 | 12 | function createTag() 13 | { 14 | return (new Tag(testTagData)).save(); 15 | } 16 | 17 | suite('/api/tags', () => 18 | { 19 | beforeAll(async () => 20 | { 21 | await clearDb(); 22 | await createTag(); 23 | }); 24 | 25 | test('GET search should return 1 result', async () => 26 | { 27 | const res = await request.get(`/api/tags?q=test`) 28 | .expect(CODES.OK); 29 | 30 | expect(res.body).to.be.an('array').with.length(1); 31 | checkTagData(res.body[0]); 32 | }) 33 | 34 | test('GET search with no results should return 404', () => 35 | { 36 | return request.get('/api/tags?q=nope') 37 | .expect(CODES.NOT_FOUND); 38 | }); 39 | 40 | test('GET empty search query should return 422', () => 41 | { 42 | return request.get('/api/tags?q=') 43 | .expect(CODES.UNPROCESSABLE_ENTITY); 44 | }); 45 | }); 46 | 47 | suite('/api/tag', () => 48 | { 49 | beforeAll(async () => 50 | { 51 | await clearDb(); 52 | await createTag(); 53 | }); 54 | 55 | test('POST creates a new tag', async () => 56 | { 57 | const res = await request.post('/api/tag') 58 | .send(testTagData) 59 | .expect(CODES.CREATED) 60 | .expect(confirmTagResponse()); 61 | 62 | const item = res.body; 63 | 64 | await request.get(`/api/tag/${item.id}`) 65 | .expect(CODES.OK) 66 | .expect(confirmTagResponse()); 67 | }); 68 | 69 | test('POST without name returns 422', async () => 70 | { 71 | return request.post('/api/tag') 72 | .send({}) 73 | .expect(CODES.UNPROCESSABLE_ENTITY); 74 | }); 75 | }); 76 | 77 | suite('/api/tag/:id', () => 78 | { 79 | beforeAll(async () => 80 | { 81 | await clearDb(); 82 | await createTag(); 83 | }); 84 | 85 | test('GET should return the test tag', () => 86 | { 87 | return request.get(`/api/tag/1`) 88 | .expect(CODES.OK) 89 | .expect(confirmTagResponse()); 90 | }); 91 | 92 | test('GET non-existant id should return 404', () => 93 | { 94 | return request.get('/api/tag/0') 95 | .expect(CODES.NOT_FOUND); 96 | }); 97 | 98 | test('PUT updates a tag', async () => 99 | { 100 | const name = 'new-name'; 101 | 102 | const res = await request.put('/api/tag/1') 103 | .send({ name }) 104 | .expect(CODES.OK); 105 | 106 | expect(res.body).to.have.property('name', name); 107 | }); 108 | 109 | test('PUT without name returns 422', () => 110 | { 111 | return request.put('/api/tag/1') 112 | .send({}) 113 | .expect(CODES.UNPROCESSABLE_ENTITY); 114 | }); 115 | }); 116 | 117 | function confirmTagResponse() 118 | { 119 | return (res: supertest.Response) => 120 | { 121 | checkTagData(res.body); 122 | }; 123 | } 124 | 125 | function checkTagData(item: ITag) 126 | { 127 | expect(item).to.have.property('name', testTagData.name); 128 | } 129 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["tslint:latest", "tslint-react"], 3 | "rules": { 4 | "jsx-alignment": true, 5 | "jsx-no-lambda": false, 6 | "jsx-no-multiline-js": true, 7 | "jsx-no-string-ref": true, 8 | "jsx-self-close": true, 9 | "jsx-boolean-value": [true, "never"], 10 | "align": [true, 11 | "parameters", 12 | "arguments", 13 | "statements" 14 | ], 15 | "array-type": [true, "array"], 16 | "arrow-parens": true, 17 | "ban": false, 18 | "class-name": true, 19 | "comment-format": [true, "check-space"], 20 | "curly": true, 21 | "eofline": true, 22 | "forin": false, 23 | "indent": [true, "spaces"], 24 | "interface-name": [true, "always-prefix"], 25 | "jsdoc-format": true, 26 | "label-position": true, 27 | "linebreak-style": [true, "LF"], 28 | "max-file-line-count": false, 29 | "max-line-length": [true, 140], 30 | "member-access": false, 31 | "member-ordering": [true, { 32 | "order": "fields-first" 33 | }], 34 | "new-parens": true, 35 | "ordered-imports": false, 36 | "no-angle-bracket-type-assertion": true, 37 | "no-any": false, 38 | "no-arg": true, 39 | "no-bitwise": false, 40 | "no-conditional-assignment": false, 41 | "no-consecutive-blank-lines": [true, 2], 42 | "no-console": [true, 43 | "debug", 44 | "info", 45 | "time", 46 | "timeEnd", 47 | "trace" 48 | ], 49 | "no-construct": true, 50 | "no-constructor-vars": false, 51 | "no-debugger": true, 52 | "no-default-export": false, 53 | "no-duplicate-variable": true, 54 | "no-empty": true, 55 | "no-empty-interface": false, 56 | "no-eval": true, 57 | "no-inferrable-types": false, 58 | "no-internal-module": true, 59 | "no-invalid-this": true, 60 | "no-mergeable-namespace": true, 61 | "no-namespace": true, 62 | "no-null-keyword": false, 63 | "no-reference": true, 64 | "no-require-imports": true, 65 | "no-shadowed-variable": true, 66 | "no-string-literal": false, 67 | "no-switch-case-fall-through": true, 68 | "no-trailing-whitespace": true, 69 | "no-unused-expression": true, 70 | "no-unused-new": true, 71 | "no-unused-variable": [true, "react"], 72 | "no-use-before-declare": true, 73 | "no-var-keyword": true, 74 | "no-var-requires": true, 75 | "object-literal-key-quotes": [true, "consistent-as-needed"], 76 | "object-literal-sort-keys": false, 77 | "one-line": [true, 78 | "check-open-brace", 79 | "check-whitespace" 80 | ], 81 | "one-variable-per-declaration": true, 82 | "only-arrow-functions": [true, "allow-declarations"], 83 | "prefer-for-of": false, 84 | "quotemark": [true, "single", "avoid-escape", "jsx-double"], 85 | "radix": true, 86 | "restrict-plus-operands": false, 87 | "semicolon": [true, "always"], 88 | "space-before-function-paren": [true, { "anonymous": "always", "named": "never", "asyncArrow": "always" }], 89 | "switch-default": false, 90 | "trailing-comma": [true, { 91 | "singleline": "never", 92 | "multiline": "always" 93 | }], 94 | "triple-equals": [true, "allow-null-check"], 95 | "typedef": [false], 96 | "typedef-whitespace": [true, { 97 | "call-signature": "nospace", 98 | "index-signature": "nospace", 99 | "parameter": "nospace", 100 | "property-declaration": "nospace", 101 | "variable-declaration": "nospace" 102 | }], 103 | "use-isnan": true, 104 | "variable-name": [true, 105 | "check-format", 106 | "allow-leading-underscore", 107 | "ban-keywords" 108 | ], 109 | "whitespace": [true, 110 | "check-branch", 111 | "check-decl", 112 | "check-operator", 113 | "check-module", 114 | "check-separator", 115 | "check-type" 116 | ] 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /server/src/routes/tags.ts: -------------------------------------------------------------------------------- 1 | // TODO: Optimistic locking failure retries! 2 | 3 | import * as CODES from 'http-codes'; 4 | import * as restify from 'restify'; 5 | import { Op } from 'sequelize'; 6 | import { Tag } from '../models/Tag'; 7 | import { db } from '../lib/db'; 8 | import { purgeCacheForUrls } from '../lib/cloudflare'; 9 | import { NotFoundError, UnprocessableEntityError } from 'restify-errors'; 10 | 11 | export function setupRoutes(app: restify.Server) 12 | { 13 | /** 14 | * GET /tags 15 | * 16 | * Searches for tags that match the given query. 17 | * 18 | * 200: The stored tag data. 19 | * 404: No data found for the given query. 20 | * 422: No query given for searching. 21 | * 500: Server error, some error happened when trying to load the tags. 22 | */ 23 | app.get('/api/tags', async (req, res) => 24 | { 25 | const { q } = req.params; 26 | 27 | if (!q) 28 | { 29 | throw new UnprocessableEntityError('Failed to search tags, query param is empty.'); 30 | } 31 | 32 | const search = `%${q}%`; 33 | 34 | const values = await Tag.findAll({ where: { name: { [Op.like]: search } } }); 35 | if (!values || !values.length) 36 | { 37 | throw new NotFoundError('No tags found during search.'); 38 | } 39 | 40 | req.log.debug(`Loaded ${values.length} tags by searching.`); 41 | res.json(CODES.OK, values); 42 | }); 43 | 44 | /** 45 | * GET /tag/:id 46 | * 47 | * Gets the data for a stored tag. 48 | * 49 | * 200: The stored tag data. 50 | * 404: No data found for the given slug. 51 | * 500: Server error, some error happened when trying to load the tag. 52 | */ 53 | app.get('/api/tag/:id', async (req, res) => 54 | { 55 | const { id } = req.params; 56 | 57 | const value = await Tag.findByPk(id); 58 | if (!value) 59 | { 60 | throw new NotFoundError(`No tag found with id: ${id}`); 61 | } 62 | 63 | req.log.debug(`Loaded tag using id: ${id}`); 64 | res.json(CODES.OK, value); 65 | }); 66 | 67 | /** 68 | * POST /tag 69 | * 70 | * Creates a new tag. 71 | * 72 | * 201: New tag created, link can be found in Link header. 73 | * 422: New tag is invalid, there are validation errors with the sent data. 74 | * 500: Server error, some error happened when trying to save the tag. 75 | */ 76 | app.post('/api/tag', async (req, res) => 77 | { 78 | let { name } = req.body; 79 | 80 | if (name) 81 | name = name.trim(); 82 | 83 | if (!name || name.length > 255) 84 | { 85 | throw new UnprocessableEntityError('Invalid param: "name" is invalid.'); 86 | } 87 | 88 | await db.transaction(async (transaction) => 89 | { 90 | const value = await Tag.create({ name }, { transaction }); 91 | req.log.debug(`Created a new tag: ${value.id}`); 92 | res.json(CODES.CREATED, value); 93 | }); 94 | }); 95 | 96 | /** 97 | * PUT /tag/:id 98 | * 99 | * Updates a tag with new data. 100 | * 101 | * 201: Tag updated, link can be found in Link header. 102 | * 422: New tag data is invalid, there are validation errors with the sent data. 103 | * 500: Server error, some error happened when trying to save the tag. 104 | */ 105 | app.put('/api/tag/:id', async (req, res) => 106 | { 107 | const { id } = req.params; 108 | let { name } = req.body; 109 | 110 | if (name) 111 | name = name.trim(); 112 | 113 | if (!name || name.length > 255) 114 | { 115 | throw new UnprocessableEntityError('Invalid param: "name" is invalid.'); 116 | } 117 | 118 | await db.transaction(async (transaction)=> 119 | { 120 | const value = await Tag.findByPk(id, { transaction }); 121 | if (!value) 122 | { 123 | throw new NotFoundError(`No tag found with id: ${id}.`); 124 | } 125 | 126 | await value.update({ name }, { transaction }); 127 | 128 | req.log.debug(`Updated tag with id ${id} to use name: ${name}.`); 129 | 130 | await purgeCacheForUrls(req.log, [ 131 | `https://pixiplayground.com/api/tag/${id}`, 132 | `https://www.pixiplayground.com/api/tag/${id}`, 133 | ]); 134 | res.json(CODES.OK, value); 135 | }); 136 | }); 137 | } 138 | -------------------------------------------------------------------------------- /server/src/migrations/00_Initial.ts: -------------------------------------------------------------------------------- 1 | import { QueryInterface, DataTypes } from 'sequelize'; 2 | import { MigrationParams } from 'umzug'; 3 | 4 | export async function up({ context: queryInterface }: MigrationParams) 5 | { 6 | await queryInterface.createTable('playgrounds', { 7 | id: { 8 | type: DataTypes.INTEGER, 9 | primaryKey: true, 10 | autoIncrement: true, 11 | }, 12 | slug: { 13 | type: DataTypes.CHAR(31), 14 | allowNull: false, 15 | }, 16 | name: { 17 | type: DataTypes.STRING(127), 18 | }, 19 | description: { 20 | type: DataTypes.STRING(511), 21 | }, 22 | contents: { 23 | type: DataTypes.TEXT({ length: 'medium' }), 24 | allowNull: false, 25 | }, 26 | author: { 27 | type: DataTypes.STRING(127), 28 | }, 29 | versionsCount: { 30 | type: DataTypes.INTEGER, 31 | defaultValue: 1, 32 | }, 33 | starCount: { 34 | type: DataTypes.INTEGER, 35 | defaultValue: 0, 36 | }, 37 | pixiVersion: { 38 | type: DataTypes.STRING(1023), 39 | allowNull: false, 40 | }, 41 | isPublic: { 42 | type: DataTypes.BOOLEAN, 43 | defaultValue: true, 44 | }, 45 | isFeatured: { 46 | type: DataTypes.BOOLEAN, 47 | defaultValue: false, 48 | }, 49 | isOfficial: { 50 | type: DataTypes.BOOLEAN, 51 | defaultValue: false, 52 | }, 53 | createdAt: { 54 | type: DataTypes.DATE, 55 | }, 56 | updatedAt: { 57 | type: DataTypes.DATE, 58 | }, 59 | lockVersion: { 60 | type: DataTypes.INTEGER, 61 | }, 62 | }); 63 | 64 | await queryInterface.addIndex('playgrounds', { 65 | name: 'playgrounds_unique_slug', 66 | type: 'UNIQUE', 67 | fields: ['slug'], 68 | }); 69 | 70 | await queryInterface.addIndex('playgrounds', { 71 | name: 'playgrounds_is_public', 72 | fields: ['isPublic'], 73 | where: { 74 | isPublic: true, 75 | }, 76 | }); 77 | 78 | await queryInterface.addIndex('playgrounds', { 79 | name: 'playgrounds_is_featured', 80 | fields: ['isFeatured'], 81 | where: { 82 | isFeatured: true, 83 | }, 84 | }); 85 | 86 | await queryInterface.addIndex('playgrounds', { 87 | name: 'playgrounds_is_official', 88 | fields: ['isOfficial'], 89 | where: { 90 | isOfficial: true, 91 | }, 92 | }); 93 | 94 | await queryInterface.addIndex('playgrounds', { 95 | name: 'fulltext_name_description_author', 96 | type: 'FULLTEXT', 97 | fields: ['name', 'description', 'author'], 98 | }); 99 | 100 | await queryInterface.createTable('tags', { 101 | id: { 102 | type: DataTypes.INTEGER, 103 | primaryKey: true, 104 | autoIncrement: true, 105 | }, 106 | name: { 107 | type: DataTypes.STRING(255), 108 | allowNull: false, 109 | }, 110 | createdAt: { 111 | type: DataTypes.DATE, 112 | }, 113 | updatedAt: { 114 | type: DataTypes.DATE, 115 | }, 116 | lockVersion: { 117 | type: DataTypes.INTEGER, 118 | }, 119 | }); 120 | 121 | await queryInterface.createTable('playground_tags', { 122 | playgroundId: { 123 | type: DataTypes.INTEGER, 124 | references: { 125 | model: 'playgrounds', 126 | key: 'id', 127 | }, 128 | onDelete: 'cascade', 129 | onUpdate: 'cascade', 130 | }, 131 | tagId: { 132 | type: DataTypes.INTEGER, 133 | references: { 134 | model: 'tags', 135 | key: 'id', 136 | }, 137 | onDelete: 'cascade', 138 | onUpdate: 'cascade', 139 | }, 140 | createdAt: { 141 | type: DataTypes.DATE, 142 | }, 143 | updatedAt: { 144 | type: DataTypes.DATE, 145 | }, 146 | lockVersion: { 147 | type: DataTypes.INTEGER, 148 | }, 149 | }); 150 | } 151 | 152 | export async function down({ context: queryInterface }: MigrationParams) 153 | { 154 | await queryInterface.dropTable('playground_tags'); 155 | await queryInterface.dropTable('tags'); 156 | await queryInterface.dropTable('playgrounds'); 157 | } 158 | -------------------------------------------------------------------------------- /server/src/models/Playground.ts: -------------------------------------------------------------------------------- 1 | import { nanoid } from 'nanoid'; 2 | import { Literal } from 'sequelize/types/utils'; 3 | import { Sequelize } from 'sequelize-typescript'; 4 | import { Table, Column, Model, DataType, BelongsToMany, HasMany } from 'sequelize-typescript'; 5 | import { db as dbConfig } from '../config'; 6 | import { db } from '../lib/db'; 7 | import { dbLogger } from '../lib/db-logger'; 8 | import { IPlayground } from '../../../shared/types'; 9 | import { Tag } from './Tag'; 10 | import { PlaygroundTag } from './PlaygroundTag'; 11 | import { ExternalJs } from './ExternalJs'; 12 | 13 | const searchQuery: { [dialect: string]: Literal } = { 14 | postgres: Sequelize.literal('"PlaygroundSearchText" @@ plainto_tsquery(\'english\', :search)'), 15 | sqlite: Sequelize.literal('(name LIKE :search) OR (description LIKE :search) OR (author LIKE :search)'), 16 | mysql: Sequelize.literal('MATCH (name, description, author) AGAINST(:search)'), 17 | }; 18 | 19 | @Table({ 20 | tableName: 'playgrounds', 21 | modelName: 'playground', 22 | timestamps: true, 23 | }) 24 | export class Playground extends Model implements IPlayground 25 | { 26 | @BelongsToMany(() => Tag, () => PlaygroundTag) 27 | declare tags: Tag[]; 28 | 29 | @HasMany(() => ExternalJs) 30 | declare externaljs: ExternalJs[]; 31 | 32 | /** 33 | * A unique identifier that is used in the URL when referring to a Playground. 34 | * 35 | */ 36 | @Column({ 37 | type: DataType.CHAR(63), 38 | allowNull: false, 39 | defaultValue: () => nanoid(), 40 | unique: 'unique_slug', 41 | // unique: 'unique_slug_version', 42 | }) 43 | declare slug: string; 44 | 45 | /** 46 | * The user-defined name of the playground. 47 | * 48 | */ 49 | @Column({ 50 | type: DataType.STRING(1023), 51 | }) 52 | declare name: string; 53 | 54 | /** 55 | * The user-defined description of the playground. 56 | * 57 | */ 58 | @Column({ 59 | type: DataType.STRING(4095), 60 | }) 61 | declare description: string; 62 | 63 | /** 64 | * The playground contents. 65 | * 66 | */ 67 | @Column({ 68 | type: DataType.TEXT({ length: 'medium' }), 69 | allowNull: false, 70 | }) 71 | declare contents: string; 72 | 73 | /** 74 | * The user-define author string. 75 | * 76 | */ 77 | @Column({ 78 | type: DataType.STRING(511), 79 | }) 80 | declare author: string; 81 | 82 | /** 83 | * The count of stars a playground has. 84 | * 85 | */ 86 | @Column({ 87 | type: DataType.INTEGER, 88 | defaultValue: 1, 89 | }) 90 | declare versionsCount: number; 91 | 92 | /** 93 | * The count of stars a playground has. 94 | * 95 | */ 96 | @Column({ 97 | type: DataType.INTEGER, 98 | defaultValue: 0, 99 | }) 100 | declare starCount: number; 101 | 102 | /** 103 | * The version of pixi this playground is built for. 104 | * 105 | */ 106 | @Column({ 107 | type: DataType.STRING(255), 108 | allowNull: false, 109 | }) 110 | declare pixiVersion: string; 111 | 112 | /** 113 | * If public is `true` (default) it will be indexed by the search engine. "Secret" 114 | * playgrounds are still visible to anyone, but are not indexed into the search engine. 115 | * 116 | */ 117 | @Column({ 118 | type: DataType.BOOLEAN, 119 | defaultValue: true, 120 | }) 121 | declare isPublic: boolean; 122 | 123 | /** 124 | * If features is `true` the playground can appear in the front-page featured section. 125 | * 126 | */ 127 | @Column({ 128 | type: DataType.BOOLEAN, 129 | defaultValue: false, 130 | }) 131 | declare isFeatured: boolean; 132 | 133 | /** 134 | * If official is `true` the playground can appear in the front-page official section. 135 | * Additionally, it can be marked as an official "example". 136 | * 137 | */ 138 | @Column({ 139 | type: DataType.BOOLEAN, 140 | defaultValue: false, 141 | }) 142 | declare isOfficial: boolean; 143 | 144 | /** 145 | * If autoUpdate is `true` (default) the preview pane will update automatically 146 | * on a timed interval. 147 | * 148 | */ 149 | @Column({ 150 | type: DataType.BOOLEAN, 151 | defaultValue: true, 152 | }) 153 | declare autoUpdate: boolean; 154 | 155 | /** 156 | * Search the pg full-text search index for the query. 157 | * 158 | */ 159 | static search(search: string): Promise 160 | { 161 | if (!searchQuery[db.options.dialect]) 162 | { 163 | dbLogger.warn('Attempt to run search on non POSTGRES database.'); 164 | return Promise.reject(new Error('Searching not supported in this dialect')); 165 | } 166 | 167 | return Playground.findAll({ 168 | where: searchQuery[dbConfig.dialect], 169 | replacements: { search }, 170 | // include: [Tag,ExternalJs], 171 | } as any) as any; // looks like types are wrong for findAll params, and Bluebird is not compat with raw promises 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /client/webpack.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | const path = require('path'); 3 | const CopyWebpackPlugin = require('copy-webpack-plugin'); 4 | const MiniCssExtractPlugin = require('mini-css-extract-plugin'); 5 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 6 | const { NoEmitOnErrorsPlugin, DefinePlugin } = require('webpack'); 7 | const MonacoEditorWebpackPlugin = require('monaco-editor-webpack-plugin'); 8 | 9 | const ASSET_PATH = 'assets'; 10 | const devMode = process.env.NODE_ENV !== 'production'; 11 | 12 | module.exports = { 13 | mode: devMode ? 'development' : 'production', 14 | devtool: 'source-map', 15 | recordsPath: path.join(__dirname, '.records'), 16 | entry: { 17 | index: './src/index.ts', 18 | results: './src/results.ts', 19 | }, 20 | output: { 21 | path: path.join(__dirname, 'dist'), 22 | publicPath: '/', 23 | filename: `${ASSET_PATH}/[name].[hash].js`, 24 | chunkFilename: `${ASSET_PATH}/[id].[hash].js`, 25 | }, 26 | resolve: { 27 | extensions: ['.ts', '.tsx', '.js'], 28 | }, 29 | devServer: { 30 | static: './dist', 31 | }, 32 | module: { 33 | rules: [ 34 | { 35 | test: /\.tsx?$/, 36 | include: [ 37 | path.resolve(__dirname, '../typings'), 38 | path.resolve(__dirname, 'typings'), 39 | path.resolve(__dirname, 'src'), 40 | ], 41 | use: [ 42 | { 43 | loader: 'ts-loader', 44 | }, 45 | ], 46 | }, 47 | { 48 | test: /\.(png|jpe?g|svg|gif|ttf|woff2?|eot)(\?v=[0-9]\.[0-9]\.[0-9])?$/i, 49 | use: [ 50 | { 51 | loader: 'file-loader', 52 | options: { 53 | name: `${ASSET_PATH}/[hash].[ext]`, 54 | }, 55 | }, 56 | { 57 | loader: 'image-webpack-loader', 58 | options: { 59 | bypassOnDebug: true, 60 | optipng: { optimizationLevel: 7 }, 61 | gifsicle: { interlaced: false }, 62 | }, 63 | }, 64 | ], 65 | }, 66 | { 67 | test: /\.less$/, 68 | include: path.resolve(__dirname, './less'), 69 | use: [ 70 | MiniCssExtractPlugin.loader, 71 | 'css-loader', 72 | // 'postcss-loader', 73 | 'less-loader', 74 | ], 75 | }, 76 | { 77 | test: /\.css$/, 78 | include: path.resolve(__dirname, './node_modules/monaco-editor'), 79 | use: [ 80 | 'style-loader', 81 | 'css-loader', 82 | ], 83 | }, 84 | { 85 | test: /\.ttf$/, 86 | include: path.resolve(__dirname, './node_modules/monaco-editor'), 87 | type: 'asset/resource', 88 | }, 89 | ], 90 | }, 91 | plugins: [ 92 | // don't emit output when there are errors 93 | new NoEmitOnErrorsPlugin(), 94 | 95 | // add monaco editor 96 | new MonacoEditorWebpackPlugin({ 97 | languages: ['json', 'javascript', 'typescript'], 98 | }), 99 | 100 | // extract inline css into separate 'styles.css' 101 | new MiniCssExtractPlugin({ 102 | filename: `${ASSET_PATH}/[name].[hash].css`, 103 | chunkFilename: `${ASSET_PATH}/[id].[hash].css`, 104 | }), 105 | 106 | // create marketing html 107 | new HtmlWebpackPlugin({ 108 | filename: 'index.html', 109 | template: './html/index.ejs', 110 | title: 'Pixi Playground', 111 | description: 'Create and view demos using pixi.js.', 112 | url: 'http://pixiplayground.com', 113 | cache: true, 114 | chunks: ['index'], 115 | }), 116 | 117 | // create marketing html 118 | new HtmlWebpackPlugin({ 119 | filename: 'results.html', 120 | template: './html/results.ejs', 121 | title: 'Pixi Playground Results', 122 | description: 'Pixi Playground Results', 123 | url: 'http://pixiplayground.com', 124 | cache: true, 125 | chunks: ['results'], 126 | }), 127 | 128 | // copy some static assets favicon stuff 129 | new CopyWebpackPlugin({ 130 | patterns: [ 131 | { 132 | from: './html/favicons/*', 133 | to: '[name][ext]', 134 | }, 135 | { 136 | from: './definitions/**', 137 | }, 138 | ], 139 | }), 140 | 141 | // add some extra defines 142 | new DefinePlugin({ 143 | __BASE_ORIGIN__: JSON.stringify(process.argv.find(v => v.includes('webpack-dev-server')) ? 'http://localhost:3000' : ''), 144 | }), 145 | ], 146 | }; 147 | -------------------------------------------------------------------------------- /client/less/editor.less: -------------------------------------------------------------------------------- 1 | @topbar-height: 33px; 2 | @loading-info-width: 500px; 3 | @loading-info-height: 125px; 4 | 5 | #editor-topbar { 6 | position: relative; 7 | display: flex; 8 | flex-wrap: wrap; 9 | height: @topbar-height; 10 | background: linear-gradient(to bottom, #eeeeee 0%,#cccccc 100%); 11 | z-index: 100; 12 | border-bottom:1px solid #999; 13 | box-sizing: border-box; 14 | 15 | .brand, .title { 16 | display:inline-block; 17 | font-size:16px; 18 | color: #333; 19 | line-height: @topbar-height; 20 | flex: auto; 21 | } 22 | 23 | .brand { 24 | font-weight:bold; 25 | .logo { 26 | vertical-align: bottom; 27 | } 28 | } 29 | 30 | .btn-group { 31 | margin:3px 2px; 32 | 33 | .btn { 34 | height: 100%; 35 | width: auto; 36 | } 37 | } 38 | 39 | @media (max-width: 540px) { 40 | .btn-group{ 41 | .btn { 42 | padding: 2px; 43 | min-width: 50px; 44 | text-align: center; 45 | font-size: 1em; 46 | } 47 | 48 | .fa { 49 | margin: 0; 50 | } 51 | .label { 52 | display: none; 53 | } 54 | } 55 | .brand span { 56 | display: none; 57 | } 58 | } 59 | } 60 | 61 | .wrap-container { 62 | width: 100%; 63 | height: calc(100% - @topbar-height); 64 | 65 | .wrap-splitter { 66 | position: absolute; 67 | top: 0; 68 | left: 50%; 69 | height: 100%; 70 | width: 2px; 71 | opacity: 0; 72 | background: #ddd; 73 | cursor: e-resize; 74 | 75 | &:hover, 76 | &.active { 77 | opacity: 1; 78 | } 79 | } 80 | 81 | .wrap-overlay { 82 | position: absolute; 83 | width: 100%; 84 | height: 100%; 85 | background: #333; 86 | opacity: 0.0; 87 | display: none; 88 | cursor: e-resize; 89 | 90 | &.active { 91 | display: block; 92 | opacity: 0.2; 93 | } 94 | 95 | } 96 | 97 | .wrap { 98 | height: 100%; 99 | } 100 | 101 | @media (max-width: 540px) { 102 | .wrap.full { 103 | width: 100% !important; 104 | } 105 | .wrap.hide { 106 | display: none; 107 | } 108 | 109 | .wrap-splitter { 110 | display: none; 111 | } 112 | } 113 | } 114 | 115 | #editor-full-wrapper { 116 | position: relative; 117 | float: left; 118 | padding: 0; 119 | margin: 0; 120 | width: 100%; 121 | height: 100%; 122 | } 123 | 124 | #editor-wrapper { 125 | position: relative; 126 | float: left; 127 | width: 50%; 128 | border-right:1px solid #999; 129 | } 130 | 131 | 132 | #results-wrapper { 133 | position: relative; 134 | float: left; 135 | width: 50%; 136 | background-color:#fff; 137 | background-image: 138 | -moz-linear-gradient(45deg, #ddd 25%, transparent 25%), 139 | -moz-linear-gradient(-45deg, #ddd 25%, transparent 25%), 140 | -moz-linear-gradient(45deg, transparent 75%, #ddd 75%), 141 | -moz-linear-gradient(-45deg, transparent 75%, #ddd 75%); 142 | background-image: 143 | -webkit-gradient(linear, 0 100%, 100% 0, color-stop(.25, #ddd), color-stop(.25, transparent)), 144 | -webkit-gradient(linear, 0 0, 100% 100%, color-stop(.25, #ddd), color-stop(.25, transparent)), 145 | -webkit-gradient(linear, 0 100%, 100% 0, color-stop(.75, transparent), color-stop(.75, #ddd)), 146 | -webkit-gradient(linear, 0 0, 100% 100%, color-stop(.75, transparent), color-stop(.75, #ddd)); 147 | -moz-background-size:20px 20px; 148 | background-size:20px 20px; 149 | -webkit-background-size:20px 21px; /* override value for shitty webkit */ 150 | background-position:0 0, 10px 0, 10px -10px, 0px 10px; 151 | } 152 | 153 | #results-frame { 154 | margin: 0; 155 | padding: 0; 156 | border: none; 157 | width: 100%; 158 | height: 100%; 159 | } 160 | 161 | #editor-loading-info { 162 | color: #fff; 163 | background: #2c3e50; 164 | display: flex; 165 | flex-direction: column; 166 | justify-content: center; 167 | align-items: center; 168 | 169 | ul { 170 | display: block; 171 | font-size: 2em; 172 | list-style: none; 173 | margin-left: -1em; 174 | width: auto; 175 | 176 | li.loading { 177 | .spinner(20px, 1px); 178 | 179 | &::before { 180 | margin-right: 8px; 181 | } 182 | 183 | > span { 184 | display: none; 185 | } 186 | } 187 | 188 | li.done > span { 189 | color: #0f0; 190 | font-size: 20px; 191 | margin-right: 8px; 192 | } 193 | } 194 | } 195 | 196 | .toggle-area { 197 | flex-basis: 100%; 198 | position: absolute; 199 | display: none; 200 | flex-direction: row-reverse; 201 | width: auto; 202 | height: auto; 203 | bottom: 20px; 204 | right: 4px; 205 | 206 | @import url("./toggle.less"); 207 | 208 | span { 209 | color: #fdd; 210 | padding:6px; 211 | letter-spacing: 0.1em; 212 | text-transform: uppercase; 213 | font-size:15px; 214 | } 215 | 216 | 217 | @media (max-width: 540px) { 218 | display: flex; 219 | } 220 | } 221 | -------------------------------------------------------------------------------- /client/src/components/MonacoEditor.tsx: -------------------------------------------------------------------------------- 1 | import * as monaco from 'monaco-editor/esm/vs/editor/editor.api'; 2 | import { h, Component } from 'preact'; 3 | import { bind } from 'decko'; 4 | 5 | function noop() { /* empty */ } 6 | 7 | interface IProps 8 | { 9 | width?: string; 10 | height?: string; 11 | value?: string; 12 | defaultValue?: string, 13 | language?: string, 14 | theme?: string, 15 | options?: monaco.editor.IEditorConstructionOptions, 16 | editorDidMount?: (editor: monaco.editor.IStandaloneCodeEditor, model: monaco.editor.ITextModel) => void, 17 | editorWillMount?: () => void, 18 | onChange?: (value: string, event: any) => void, 19 | requireConfig?: any, 20 | } 21 | 22 | export class MonacoEditor extends Component 23 | { 24 | editor: monaco.editor.IStandaloneCodeEditor; 25 | model: monaco.editor.ITextModel; 26 | 27 | private _currentValue: string; 28 | private _preventTriggerChangeEvent: boolean; 29 | private _containerElement: HTMLDivElement; 30 | 31 | constructor(props: IProps, context: any) 32 | { 33 | super(props, context); 34 | 35 | this.editor = null; 36 | 37 | this._currentValue = props.value; 38 | this._preventTriggerChangeEvent = false; 39 | this._containerElement = null; 40 | } 41 | 42 | componentDidMount() 43 | { 44 | this.initMonaco(); 45 | } 46 | 47 | componentWillUnmount() 48 | { 49 | this.destroyMonaco(); 50 | } 51 | 52 | componentDidUpdate(prevProps: IProps) 53 | { 54 | if (this.props.value !== this._currentValue) 55 | { 56 | // Always refer to the latest value 57 | this._currentValue = this.props.value; 58 | 59 | // Consider the situation of rendering 1+ times before the editor mounted 60 | if (this.editor) 61 | { 62 | this._preventTriggerChangeEvent = true; 63 | this.editor.pushUndoStop(); 64 | this.model.pushEditOperations( 65 | [], 66 | [ 67 | { 68 | range: this.model.getFullModelRange(), 69 | text: this._currentValue, 70 | }, 71 | ], 72 | undefined, 73 | ); 74 | this.editor.popUndoStop(); 75 | this._preventTriggerChangeEvent = false; 76 | } 77 | } 78 | 79 | if (prevProps.language !== this.props.language) 80 | { 81 | monaco.editor.setModelLanguage(this.editor.getModel(), this.props.language); 82 | } 83 | 84 | if (prevProps.theme !== this.props.theme) 85 | { 86 | monaco.editor.setTheme(this.props.theme); 87 | } 88 | } 89 | 90 | editorWillMount() 91 | { 92 | const { editorWillMount } = this.props; 93 | 94 | editorWillMount(); 95 | } 96 | 97 | editorDidMount(editor: monaco.editor.IStandaloneCodeEditor, model: monaco.editor.ITextModel) 98 | { 99 | const { editorDidMount, onChange } = this.props; 100 | 101 | editorDidMount(editor, model); 102 | 103 | editor.onDidChangeModelContent((event) => 104 | { 105 | const value = editor.getValue(); 106 | 107 | // Always refer to the latest value 108 | this._currentValue = value; 109 | 110 | // Only invoking when user input changed 111 | if (!this._preventTriggerChangeEvent) 112 | { 113 | onChange(value, event); 114 | } 115 | }); 116 | } 117 | 118 | @bind 119 | containerDidMount(containerElement: HTMLDivElement) 120 | { 121 | this._containerElement = containerElement; 122 | } 123 | 124 | initMonaco() 125 | { 126 | if (!this._containerElement) 127 | return; 128 | 129 | const value = this.props.value !== null ? this.props.value : this.props.defaultValue; 130 | const { language, theme, options } = this.props; 131 | 132 | // Before initializing monaco editor 133 | this.editorWillMount(); 134 | 135 | this.model = monaco.editor.createModel(value, language); 136 | this.editor = monaco.editor.create(this._containerElement, { 137 | model: this.model, 138 | theme, 139 | ...options, 140 | }); 141 | 142 | // After initializing monaco editor 143 | this.editorDidMount(this.editor, this.model); 144 | } 145 | 146 | destroyMonaco() 147 | { 148 | if (typeof this.editor !== 'undefined') 149 | { 150 | this.editor.dispose(); 151 | } 152 | } 153 | 154 | render() 155 | { 156 | const { width, height } = this.props; 157 | const fixedWidth = width.toString().indexOf('%') !== -1 ? width : `${width}px`; 158 | const fixedHeight = height.toString().indexOf('%') !== -1 ? height : `${height}px`; 159 | const style = { 160 | width: fixedWidth, 161 | height: fixedHeight, 162 | }; 163 | 164 | return
; 165 | } 166 | } 167 | 168 | MonacoEditor.defaultProps = { 169 | width: '100%', 170 | height: '100%', 171 | value: null, 172 | defaultValue: '', 173 | language: 'javascript', 174 | theme: 'vs', 175 | options: {}, 176 | editorDidMount: noop, 177 | editorWillMount: noop, 178 | onChange: noop, 179 | requireConfig: {}, 180 | }; 181 | -------------------------------------------------------------------------------- /server/src/routes/playgrounds.ts: -------------------------------------------------------------------------------- 1 | // TODO: Optimistic locking failure retries! 2 | 3 | import CODES from 'http-codes'; 4 | import * as restify from 'restify'; 5 | import * as bunyan from 'bunyan'; 6 | import { InternalServerError, NotFoundError, UnprocessableEntityError } from 'restify-errors'; 7 | import { Tag } from '../models/Tag'; 8 | import { Playground } from '../models/Playground'; 9 | import { ExternalJs } from '../models/ExternalJs'; 10 | import { db } from '../lib/db'; 11 | import { HttpError } from '../lib/HttpError'; 12 | import { ITag, IExternalJs } from '../../../shared/types'; 13 | import { purgeCacheForUrls } from '../lib/cloudflare'; 14 | 15 | export function setupRoutes(app: restify.Server) 16 | { 17 | /** 18 | * GET /playgrounds 19 | * 20 | * Searches for playgrounds that match the given query. 21 | * 22 | * 200: The stored playground data. 23 | * 404: No data found for the given query. 24 | * 422: Invalid query given for searching. 25 | * 500: Server error, some error happened when trying to load the playgrounds. 26 | */ 27 | app.get('/api/playgrounds', async (req, res) => 28 | { 29 | const { q } = req.params; 30 | 31 | if (!q) 32 | { 33 | throw new UnprocessableEntityError('Failed to search playgrounds, query param is empty.'); 34 | } 35 | 36 | const values = await Playground.search(q); 37 | if (!values || !values.length) 38 | { 39 | throw new NotFoundError('No playgrounds found during search.'); 40 | } 41 | 42 | req.log.debug(`Loaded ${values.length} playgrounds by searching.`); 43 | res.json(CODES.OK, values); 44 | }); 45 | 46 | /** 47 | * GET /playground/:slug 48 | * 49 | * Gets the data for a stored playground. 50 | * 51 | * 200: The stored playground data. 52 | * 404: No data found for the given slug. 53 | * 500: Server error, some error happened when trying to load the playground. 54 | */ 55 | app.get('/api/playground/:slug', async (req, res) => 56 | { 57 | const { slug } = req.params; 58 | 59 | const value = await Playground.findOne({ where: { slug }, include: [Tag, ExternalJs] }); 60 | if (!value) 61 | { 62 | throw new NotFoundError(`No playground found with slug: ${slug}`); 63 | } 64 | 65 | req.log.debug(`Loaded playground using slug: ${slug}`); 66 | res.json(CODES.OK, value); 67 | }); 68 | 69 | /** 70 | * POST /playground 71 | * 72 | * Creates a new playground. 73 | * 74 | * 201: New playground created, link can be found in Link header. 75 | * 422: New playground is invalid, there are validation errors with the sent data. 76 | * 500: Server error, some error happened when trying to save the playground. 77 | */ 78 | app.post('/api/playground', async (req, res) => 79 | { 80 | const { name, description, contents, author, pixiVersion, isPublic, autoUpdate } = req.body; 81 | const tagsData: ITag[] = req.body.tags || []; 82 | const externaljsData: IExternalJs[] = req.body.externaljs || []; 83 | 84 | if (!contents || contents.length > 16777214 85 | || !pixiVersion || pixiVersion.length > 1023) 86 | { 87 | throw new UnprocessableEntityError('Invalid params: "contents" or "pixiVersion" is invalid.'); 88 | } 89 | 90 | await db.transaction(async (transaction) => 91 | { 92 | const value = await Playground.create( 93 | { name, description, contents, author, pixiVersion, isPublic, autoUpdate }, 94 | { transaction }); 95 | 96 | await prepareExternaljs(value, transaction, externaljsData); 97 | 98 | req.log.debug(`Created a new playground: ${value.slug}`); 99 | 100 | if (tagsData.length) 101 | { 102 | const tags = prepareTags(req.log, tagsData); 103 | await value.$set('tags', tags, { transaction }); 104 | } 105 | 106 | const fullObj = await Playground.findByPk(value.id, { 107 | include: [Tag, ExternalJs], 108 | transaction, 109 | }); 110 | 111 | res.json(CODES.CREATED, fullObj); 112 | }); 113 | }); 114 | 115 | /** 116 | * PUT /playground/:slug 117 | * 118 | * Updates a playground with a new version. 119 | * 120 | * 201: New playground version created, link can be found in Link header. 121 | * 422: New playground version is invalid, there are validation errors with the sent data. 122 | * 500: Server error, some error happened when trying to save the playground version. 123 | */ 124 | app.put('/api/playground/:slug', async (req, res) => 125 | { 126 | const { slug } = req.params; 127 | const { id, name, description, contents, author, pixiVersion, isPublic, autoUpdate } = req.body; 128 | const tagsData: ITag[] = req.body.tags || []; 129 | const externaljsData: IExternalJs[] = req.body.externaljs || []; 130 | 131 | if (!slug || slug.length !== 21 132 | || !contents || contents.length > 16777214 133 | || !pixiVersion || pixiVersion.length > 1023) 134 | { 135 | throw new UnprocessableEntityError('Invalid params: "slug", "contents", or "pixiVersion" is invalid.'); 136 | } 137 | 138 | await db.transaction(async (transaction) => 139 | { 140 | const value = await Playground.findByPk(id, { transaction }); 141 | if (!value) 142 | { 143 | throw new NotFoundError(`No playground found with id: ${id}`); 144 | } 145 | 146 | await value.update( 147 | { name, description, contents, author, pixiVersion, isPublic, autoUpdate, versionsCount: value.versionsCount + 1 }, 148 | { transaction }); 149 | 150 | await ExternalJs.destroy({ 151 | where: { playgroundId: value.id }, 152 | transaction, 153 | }); 154 | 155 | await prepareExternaljs(value, transaction, externaljsData); 156 | 157 | if (value.slug !== slug) 158 | { 159 | throw new InternalServerError(`Playground found with id: ${id}, but has mismatched slug. Expected '${slug}', but got '${value.slug}'.`); 160 | } 161 | 162 | req.log.debug(`Updated playground with slug: ${slug}, added version: ${value.versionsCount}`); 163 | 164 | if (tagsData.length) 165 | { 166 | const tags = prepareTags(req.log, tagsData); 167 | await value.$set('tags', tags, { transaction }); 168 | } 169 | 170 | const fullObj = await Playground.findByPk(value.id, { 171 | include: [Tag, ExternalJs], 172 | transaction, 173 | }); 174 | 175 | await purgeCacheForUrls(req.log, [ 176 | `https://pixiplayground.com/api/playground/${slug}`, 177 | `https://www.pixiplayground.com/api/playground/${slug}`, 178 | ]); 179 | 180 | res.json(CODES.OK, fullObj); 181 | }); 182 | }); 183 | }; 184 | 185 | function prepareTags(log: bunyan, tagsData: ITag[]): Tag[] 186 | { 187 | const tags: Tag[] = []; 188 | 189 | for (let i = 0; i < tagsData.length; ++i) 190 | { 191 | if (tagsData[i] && typeof tagsData[i].id === 'number') 192 | { 193 | tags.push(new Tag({ id: tagsData[i].id })); 194 | } 195 | else 196 | { 197 | log.info(`Invalid tag listed in create, skipping. Tag: ${JSON.stringify(tagsData[i])}`); 198 | } 199 | } 200 | 201 | return tags; 202 | } 203 | 204 | function prepareExternaljs(value: Playground, t: any, externaljsData: IExternalJs[]) 205 | { 206 | const externaljsTasks = []; 207 | 208 | for (let i = 0; i < externaljsData.length; ++i) 209 | { 210 | if (!externaljsData[i]) 211 | continue; 212 | 213 | let url = externaljsData[i].url; 214 | 215 | if (!url || typeof url !== 'string') 216 | continue; 217 | 218 | url = url.trim(); 219 | 220 | if (!url || url.length > 1023) 221 | continue; 222 | 223 | externaljsTasks.push(value.$create( 224 | 'externalj', // Sequelize removes the 's' of 'externaljs' 225 | { url }, 226 | { transaction: t })); 227 | } 228 | 229 | return Promise.all(externaljsTasks); 230 | } 231 | -------------------------------------------------------------------------------- /client/less/dialog.less: -------------------------------------------------------------------------------- 1 | /* The Modal (background) */ 2 | .modal { 3 | display: none; /* Hidden by default */ 4 | position: fixed; /* Stay in place */ 5 | z-index: 150; /* Sit on top */ 6 | left: 0; 7 | top: 0; 8 | width: 100%; /* Full width */ 9 | height: 100%; /* Full height */ 10 | overflow: auto; /* Enable scroll if needed */ 11 | background-color: rgb(0,0,0); /* Fallback color */ 12 | background-color: rgba(0, 0, 0, 0.6); /* Black w/ opacity */ 13 | justify-content: center; 14 | 15 | /* Modal Content/Box */ 16 | .modal-content { 17 | margin-top: 70px; 18 | width: 100%; 19 | max-width: 520px; 20 | height: 75vh; 21 | padding: 2px; 22 | border-radius: 4px; 23 | background-color: #fefefe; 24 | 25 | header { 26 | background: linear-gradient(to bottom, #eeeeee 0%,#cccccc 100%); 27 | border-radius: 3px 3px 0 0; 28 | padding: 5px 10px; 29 | display: flex; 30 | flex-direction: row; 31 | align-items: center; 32 | 33 | .title { 34 | margin: 0; 35 | padding: 2px; 36 | flex: auto; 37 | } 38 | 39 | .close-btn { 40 | cursor: pointer; 41 | margin: 0px 10px; 42 | 43 | color: rgba(0, 0, 0, 0.6); 44 | 45 | &:hover { 46 | color: rgba(0, 0, 0, 0.8); 47 | } 48 | } 49 | } 50 | 51 | .form-wrapper { 52 | height: calc(75vh - 47px); 53 | form { 54 | height: 100%; 55 | overflow-y: auto; 56 | overflow-x: hidden; 57 | padding: 20px; 58 | color: #f6f6f6; 59 | 60 | background: linear-gradient(#252525, #1e1e1e); 61 | border: 1px solid #000; 62 | border-radius: 0 0 3px 3px; 63 | box-shadow: inset 0 0 0 1px #272727; 64 | 65 | /* BEGIN Scrollbar style */ 66 | scrollbar-face-color:#666; 67 | scrollbar-track-color:none; 68 | 69 | &::-webkit-scrollbar { 70 | width: 0.5em; 71 | height: 0.5em 72 | } 73 | 74 | &::-webkit-scrollbar-thumb { 75 | background: #666 76 | } 77 | 78 | &::-webkit-scrollbar-track { 79 | background: none 80 | } 81 | /* END Scrollbar style */ 82 | 83 | h4 { 84 | padding: 0; 85 | margin: 0 0 4px 0; 86 | font-weight: bold; 87 | line-height: 1.2; 88 | } 89 | 90 | input { 91 | margin: 0 0 5px 0; 92 | } 93 | 94 | .externaljs-row { 95 | input { 96 | width: calc(100% - 15px); 97 | } 98 | 99 | span { 100 | cursor: pointer; 101 | padding: 7px 0px 7px 4px; 102 | vertical-align: middle; 103 | color: #e72266; 104 | } 105 | } 106 | 107 | input[type="text"] { 108 | padding: 6px; 109 | background: #eaeff0; 110 | border-radius: 3px; 111 | border: 2px solid #eaedf0; 112 | outline: 0; 113 | 114 | &:focus { 115 | border-color: #e72266; 116 | } 117 | } 118 | 119 | input[type="checkbox"] { 120 | position: absolute; 121 | opacity: 0; 122 | 123 | & + label { 124 | position: relative; 125 | cursor: pointer; 126 | padding: 0; 127 | } 128 | 129 | // Box. 130 | & + label::before { 131 | content: ''; 132 | margin-right: 10px; 133 | display: inline-block; 134 | vertical-align: text-top; 135 | width: 20px; 136 | height: 20px; 137 | background: #e72266; 138 | } 139 | 140 | // Box hover 141 | &:hover + label::before { 142 | background: #fdd; 143 | } 144 | 145 | // Box focus 146 | &:focus + label::before { 147 | box-shadow: 0 0 0 3px rgba(0, 0, 0, 0.12); 148 | } 149 | 150 | // Disabled state label. 151 | &:disabled + label { 152 | color: #b8b8b8; 153 | cursor: auto; 154 | } 155 | 156 | // Disabled box. 157 | &:disabled + label::before { 158 | box-shadow: none; 159 | background: #ddd; 160 | } 161 | 162 | // Checkmark. Could be replaced with an image 163 | &:checked + label::after { 164 | /* BEGIN Font-Awesome */ 165 | font-weight: 900; 166 | font-family: "Font Awesome 5 Free"; 167 | -moz-osx-font-smoothing: grayscale; 168 | -webkit-font-smoothing: antialiased; 169 | display: inline-block; 170 | font-style: normal; 171 | font-variant: normal; 172 | text-rendering: auto; 173 | line-height: 1; 174 | /* END Font-Awesome */ 175 | 176 | content: '\f00c'; // fa-check 177 | position: absolute; 178 | left: 2px; 179 | top: 2px; 180 | color: #fff; 181 | } 182 | } 183 | 184 | .fullwidth { 185 | width: 100%; 186 | } 187 | 188 | fieldset { 189 | padding: 0 0 10px 0; 190 | margin: 0; 191 | border: none; 192 | } 193 | 194 | .stg-rows-group { 195 | .stg-row { 196 | margin-top: 4px; 197 | } 198 | } 199 | 200 | .radio-group { 201 | clear: both; 202 | display: inline-block; 203 | 204 | .radio-btn { 205 | position: absolute; 206 | left: -9999em; 207 | top: -9999em; 208 | 209 | & + label { 210 | font-family: 'Roboto', Helvetica, sans-serif; 211 | border: 1px solid #c11953; 212 | background-color: #e72266; 213 | color: #fdd; 214 | padding: 6px 15px; 215 | letter-spacing: 0.1em; 216 | // text-transform: uppercase; 217 | font-size: 10px; 218 | cursor:pointer; 219 | // border-radius: 2px; 220 | margin: 0; 221 | outline: none; 222 | transition: all 0.2s; 223 | text-shadow: 0 1px 1px rgba(0, 0, 0, 0.3); 224 | 225 | float: left; 226 | 227 | &:first-of-type { 228 | border-radius: 2px 0 0 2px; 229 | } 230 | 231 | &:last-of-type { 232 | border-radius: 0 2px 2px 0; 233 | } 234 | } 235 | 236 | &:checked + label { 237 | color:#fff; 238 | box-shadow: 0 0 0 1px rgba(255,255,255,0.8) inset; 239 | border-color:#8e113c; 240 | } 241 | } 242 | } 243 | } 244 | } 245 | } 246 | 247 | /* The Close Button */ 248 | .close { 249 | color: #aaa; 250 | float: right; 251 | font-size: 28px; 252 | font-weight: bold; 253 | } 254 | 255 | .close:hover, 256 | .close:focus { 257 | color: black; 258 | text-decoration: none; 259 | cursor: pointer; 260 | } 261 | } 262 | -------------------------------------------------------------------------------- /server/test/specs/routes/playgrounds.ts: -------------------------------------------------------------------------------- 1 | import * as CODES from 'http-codes'; 2 | import * as supertest from 'supertest'; 3 | import { beforeAll, expect, suite, test } from 'vitest'; 4 | import { request, clearDb } from '../../fixtures/server'; 5 | import { Playground } from '../../../src/models/Playground'; 6 | import { Tag } from '../../../src/models/Tag'; 7 | import { IPlayground, ITag, IExternalJs } from '../../../../shared/types'; 8 | 9 | const testPlaygroundData: IPlayground = { 10 | slug: 'hN4tERudnG0mMDZrafh7U', 11 | name: 'test', 12 | description: undefined, 13 | contents: 'STUFF!', 14 | author: 'Chad Engler', 15 | pixiVersion: 'v5.0.0', 16 | isPublic: undefined, 17 | externaljs: [], 18 | tags: [], 19 | }; 20 | 21 | function createPlayground() 22 | { 23 | return (new Playground(testPlaygroundData as any)).save(); 24 | } 25 | 26 | suite('/api/playgrounds', () => 27 | { 28 | beforeAll(async () => 29 | { 30 | await clearDb(); 31 | await createPlayground(); 32 | }); 33 | 34 | test('GET search should return 1 result', async () => 35 | { 36 | const res = await request.get(`/api/playgrounds?q=test`) 37 | .expect(CODES.OK); 38 | 39 | expect(res.body).to.be.an('array').with.length(1); 40 | 41 | checkPlaygroundData(res.body[0]); 42 | checkPlaygroundExtras(res.body[0], testPlaygroundData.slug, null, null); 43 | }); 44 | 45 | test('GET search with no results should return 404', () => 46 | { 47 | return request.get('/api/playgrounds?q=nope') 48 | .expect(CODES.NOT_FOUND); 49 | }); 50 | 51 | test('GET empty search query should return 422', () => 52 | { 53 | return request.get('/api/playgrounds?q=') 54 | .expect(CODES.UNPROCESSABLE_ENTITY); 55 | }); 56 | }); 57 | 58 | suite('/api/playground', () => 59 | { 60 | beforeAll(async () => 61 | { 62 | await clearDb(); 63 | await createPlayground(); 64 | }); 65 | 66 | test('POST creates a new playground', async () => 67 | { 68 | const res = await request.post('/api/playground') 69 | .send(testPlaygroundData) 70 | .expect(CODES.CREATED) 71 | .expect(confirmPlaygroundResponse(null)); 72 | 73 | const item = res.body; 74 | expect(item) 75 | .to.have.property('externaljs') 76 | .that.is.an('array') 77 | .with.length(0); 78 | 79 | // return request.get(`/api/playground/${item.slug}/${item.version}`) 80 | await request.get(`/api/playground/${item.slug}`) 81 | .expect(CODES.OK) 82 | .expect(confirmPlaygroundResponse(item.slug)); 83 | }); 84 | 85 | test('POST creates a new playground with externaljs', () => 86 | { 87 | const externaljs = [{ url: 'https://test.com/file.js' }]; 88 | const data = { ...testPlaygroundData }; 89 | data.externaljs = externaljs; 90 | 91 | return request.post('/api/playground') 92 | .send(data) 93 | .expect(CODES.CREATED) 94 | .expect(confirmPlaygroundResponse(null, null, externaljs)); 95 | }); 96 | 97 | test('POST without contents returns 422', () => 98 | { 99 | const data = { ...testPlaygroundData }; 100 | data.contents = ''; 101 | 102 | return request.post('/api/playground') 103 | .send(data) 104 | .expect(CODES.UNPROCESSABLE_ENTITY); 105 | }); 106 | 107 | test('POST with tags should create associations', async () => 108 | { 109 | const tag1 = await Tag.create({ name: 'tag1' }); 110 | const tag2 = await Tag.create({ name: 'tag2' }); 111 | 112 | const tag11 = await Tag.findByPk(tag1.id); 113 | const tag21 = await Tag.findByPk(tag2.id); 114 | 115 | const data = { ...testPlaygroundData }; 116 | data.tags = [{ id: tag1.id }, { id: tag2.id }]; 117 | 118 | const res1 = await request.post('/api/playground') 119 | .send(data) 120 | .expect(CODES.CREATED) 121 | .expect(confirmPlaygroundResponse( 122 | null, 123 | [ 124 | { id: tag1.id, name: tag1.name }, 125 | { id: tag2.id, name: tag2.name }, 126 | ])); 127 | 128 | await request.get(`/api/playground/${res1.body.slug}`) 129 | .expect(CODES.OK) 130 | .expect(confirmPlaygroundResponse( 131 | null, 132 | [ 133 | { id: tag1.id, name: tag1.name }, 134 | { id: tag2.id, name: tag2.name }, 135 | ])); 136 | }); 137 | }); 138 | 139 | suite('/api/playground/:slug', () => 140 | { 141 | beforeAll(async () => 142 | { 143 | await clearDb(); 144 | await createPlayground(); 145 | }); 146 | 147 | test('GET without version should return version 0', () => 148 | { 149 | return request.get(`/api/playground/${testPlaygroundData.slug}`) 150 | .expect(CODES.OK) 151 | .expect(confirmPlaygroundResponse()); 152 | }); 153 | 154 | // test('GET with version should return proper data', () => 155 | // { 156 | // return request.get(`/api/playground/${testPlaygroundData.slug}/0`) 157 | // .expect(CODES.OK) 158 | // .expect(confirmPlaygroundData()); 159 | // }); 160 | 161 | test('GET non-existent slug should return 404', () => 162 | { 163 | return request.get('/api/playground/nope') 164 | .expect(CODES.NOT_FOUND); 165 | }); 166 | 167 | // test('GET invalid version should return 422', () => 168 | // { 169 | // return request.get(`/api/playground/${testPlaygroundData.slug}/nope`) 170 | // .expect(CODES.UNPROCESSABLE_ENTITY); 171 | // }); 172 | 173 | // test('GET non-existent version should return 404', () => 174 | // { 175 | // return request.get(`/api/playground/${testPlaygroundData.slug}/100`) 176 | // .expect(CODES.NOT_FOUND); 177 | // }); 178 | 179 | // test('GET non-existent slug/version should return 404', () => 180 | // { 181 | // return request.get(`/api/playground/nope/100`) 182 | // .expect(CODES.NOT_FOUND); 183 | // }); 184 | 185 | // test('POST with slug creates a new playground version', async () => 186 | // { 187 | // const res = await request.post(`/api/playground/${testPlaygroundData.slug}`) 188 | // .send({ ...testPlaygroundData }) 189 | // .expect(CODES.OK) 190 | // .expect(confirmPlaygroundData(testPlaygroundData.slug, 1)); 191 | 192 | // const item = res.body; 193 | 194 | // await request.get(`/api/${item.slug}/${item.version}`) 195 | // .expect(CODES.OK) 196 | // .expect(confirmPlaygroundData(testPlaygroundData.slug, 1)); 197 | // }); 198 | 199 | test('PUT updates a playground', async () => 200 | { 201 | const name = 'new-name'; 202 | const data = { id: 1, ...testPlaygroundData }; 203 | data.name = name; 204 | 205 | const res = await request.put(`/api/playground/${data.slug}`) 206 | .send(data) 207 | .expect(CODES.OK); 208 | 209 | expect(res.body).to.have.property('name', name); 210 | }); 211 | 212 | test('PUT without contents returns 422', () => 213 | { 214 | const data = { id: 1, ...testPlaygroundData }; 215 | data.contents = ''; 216 | 217 | return request.put(`/api/playground/${testPlaygroundData.slug}`) 218 | .send(data) 219 | .expect(CODES.UNPROCESSABLE_ENTITY); 220 | }); 221 | 222 | test('PUT without id returns 404', () => 223 | { 224 | const data = { ...testPlaygroundData }; 225 | 226 | return request.put(`/api/playground/${testPlaygroundData.slug}`) 227 | .send(data) 228 | .expect(CODES.NOT_FOUND); 229 | }); 230 | 231 | test('PUT with tags should create associations', async () => 232 | { 233 | const tag1 = new Tag({ name: 'tag1' }); 234 | const tag2 = new Tag({ name: 'tag2' }); 235 | 236 | await tag1.save(); 237 | await tag2.save(); 238 | 239 | const data = { id: 1, ...testPlaygroundData }; 240 | data.tags = [{ id: tag1.id }, { id: tag2.id }]; 241 | 242 | await request.put(`/api/playground/${testPlaygroundData.slug}`) 243 | .send(data) 244 | .expect(CODES.OK); 245 | 246 | const res = await request.get(`/api/playground/${testPlaygroundData.slug}`) 247 | .expect(CODES.OK); 248 | 249 | const item = res.body; 250 | 251 | expect(item) 252 | .to.have.property('tags') 253 | .with.length(2); 254 | 255 | expect(item.tags[0]).to.have.property('id', tag1.id); 256 | expect(item.tags[0]).to.have.property('name', tag1.name); 257 | expect(item.tags[1]).to.have.property('id', tag2.id); 258 | expect(item.tags[1]).to.have.property('name', tag2.name); 259 | }); 260 | 261 | test('PUT with different tags should create associations', async () => 262 | { 263 | const tag1 = new Tag({ name: 'tag1' }); 264 | const tag2 = new Tag({ name: 'tag2' }); 265 | 266 | await tag1.save(); 267 | await tag2.save(); 268 | 269 | const data = { id: 1, ...testPlaygroundData }; 270 | data.tags = [{ id: tag1.id }, { id: tag2.id }]; 271 | 272 | await request.put(`/api/playground/${testPlaygroundData.slug}`) 273 | .send(data) 274 | .expect(CODES.OK); 275 | 276 | const res = await request.get(`/api/playground/${testPlaygroundData.slug}`) 277 | .expect(CODES.OK); 278 | 279 | const item = res.body; 280 | 281 | expect(item) 282 | .to.have.property('tags') 283 | .with.length(2); 284 | 285 | expect(item.tags[0]).to.have.property('id', tag1.id); 286 | expect(item.tags[0]).to.have.property('name', tag1.name); 287 | expect(item.tags[1]).to.have.property('id', tag2.id); 288 | expect(item.tags[1]).to.have.property('name', tag2.name); 289 | }); 290 | }); 291 | 292 | function confirmPlaygroundResponse(slug: string = testPlaygroundData.slug, tags: ITag[] = null, externaljs: IExternalJs[] = []) 293 | { 294 | return (res: supertest.Response) => 295 | { 296 | checkPlaygroundData(res.body); 297 | checkPlaygroundExtras(res.body, slug, tags, externaljs); 298 | }; 299 | } 300 | 301 | function checkPlaygroundData(item: IPlayground) 302 | { 303 | expect(item).to.have.property('id').that.is.a('number'); 304 | expect(item).to.have.property('name', testPlaygroundData.name); 305 | expect(item).to.have.property('description', null); 306 | expect(item).to.have.property('contents', testPlaygroundData.contents); 307 | expect(item).to.have.property('author', testPlaygroundData.author); 308 | expect(item).to.have.property('versionsCount', 1); 309 | expect(item).to.have.property('starCount', 0); 310 | expect(item).to.have.property('pixiVersion', testPlaygroundData.pixiVersion); 311 | expect(item).to.have.property('isPublic', true); 312 | expect(item).to.have.property('isFeatured', false); 313 | expect(item).to.have.property('isOfficial', false); 314 | expect(item).to.have.property('createdAt').that.is.a('string'); 315 | expect(item).to.have.property('updatedAt').that.is.a('string'); 316 | } 317 | 318 | function checkPlaygroundExtras(item: IPlayground, slug: string, tags: ITag[], externaljs: IExternalJs[]) 319 | { 320 | if (slug) 321 | expect(item).to.have.property('slug', slug); 322 | 323 | if (tags) 324 | { 325 | expect(item).to.have.property('tags').with.length(tags.length); 326 | 327 | for (let i = 0; i < tags.length; ++i) 328 | { 329 | expect(item.tags[i]).to.have.property('id', tags[i].id); 330 | expect(item.tags[i]).to.have.property('name', tags[i].name); 331 | } 332 | 333 | } 334 | 335 | if (externaljs) 336 | { 337 | expect(item).to.have.property('externaljs').with.length(externaljs.length); 338 | 339 | for (let i = 0; i < externaljs.length; ++i) 340 | { 341 | expect(item.externaljs[i]).to.have.property('url', externaljs[i].url); 342 | } 343 | } 344 | } 345 | -------------------------------------------------------------------------------- /client/src/components/EditorSettingsDialog.tsx: -------------------------------------------------------------------------------- 1 | import linkState from 'linkstate'; 2 | import { h, Component } from 'preact'; 3 | import { bind } from 'decko'; 4 | import { IPlayground, IExternalJs } from '../../../shared/types'; 5 | import { Radio, RadioGroup } from './Radio'; 6 | import { getReleases } from '../service'; 7 | import { assertNever } from '../util/assertNever'; 8 | import { PixiVersionType, getPixiVersionType } from '../util/pixiVersionType'; 9 | 10 | interface IProps 11 | { 12 | data: IPlayground; 13 | visible: boolean; 14 | onSaveClick?: (data: IPlayground) => void; 15 | onCloseClick?: () => void; 16 | } 17 | 18 | interface IState 19 | { 20 | data: IPlayground; 21 | versionType: PixiVersionType; 22 | versionOptions: string[]; 23 | } 24 | 25 | export class EditorSettingsDialog extends Component 26 | { 27 | constructor(props: IProps, context: any) 28 | { 29 | super(props, context); 30 | 31 | this.state = { 32 | data: props.data, 33 | versionType: PixiVersionType.Release, 34 | versionOptions: [], 35 | }; 36 | 37 | getReleases((err, releases) => 38 | { 39 | if (!err) 40 | { 41 | this.setState({ versionOptions: releases }); 42 | } 43 | }); 44 | } 45 | 46 | updatePlaygroundData(data: IPlayground) 47 | { 48 | const versionType = getPixiVersionType(data.pixiVersion); 49 | this.setState({ data, versionType }); 50 | } 51 | 52 | render(props: IProps, state: IState) 53 | { 54 | return ( 55 |
56 |
57 |
58 |

Playground Settings

59 |
60 | 64 |
65 | 66 |
67 |
68 |
69 |
70 |

71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 |
82 | 83 | { 84 | state.versionType === PixiVersionType.Tag ? this._renderTagVersionSelector(state) 85 | : state.versionType === PixiVersionType.Custom ? this._renderCustomVersionSelector(state) 86 | : '' 87 | } 88 |
89 | 90 |
91 |

92 | 100 |
101 | 102 |
103 |

104 | 112 |
113 | 114 |
115 |

116 | 124 |
125 | 126 |
127 |

128 | 129 | { (state.data.externaljs || []).map(this._renderExternaljs) } 130 | 131 | 135 |
136 | 137 |
138 |

139 |
140 | 146 | 151 |
152 |
153 | 159 | 164 |
165 |
166 |
167 |
168 |
169 |
170 | ); 171 | } 172 | 173 | private _renderTagVersionSelector(state: IState) 174 | { 175 | return ( 176 | 183 | ); 184 | } 185 | 186 | private _renderCustomVersionSelector(state: IState) 187 | { 188 | return ( 189 | 196 | ); 197 | } 198 | 199 | @bind 200 | private _renderTagOption(tag: string, index: number) 201 | { 202 | return ( 203 | 204 | ); 205 | } 206 | 207 | @bind 208 | private _renderExternaljs(externaljs: IExternalJs, index: number) 209 | { 210 | return ( 211 |
212 | 219 | 224 |
225 | ); 226 | } 227 | 228 | // private _renderShaSelector(state: IState) 229 | // { 230 | // return ( 231 | //
232 | // 238 | // 239 | //
240 | // ); 241 | // } 242 | 243 | @bind 244 | private _onAddExternaljs(e: Event) 245 | { 246 | e.preventDefault(); 247 | 248 | if (!this.state.data.externaljs) 249 | this.state.data.externaljs = []; 250 | 251 | this.state.data.externaljs.push({ url: '' }); 252 | this.setState({ data: this.state.data }); 253 | } 254 | 255 | @bind 256 | private _onRemoveExternaljs(e: Event) 257 | { 258 | const target = e.target as HTMLInputElement; 259 | const index = parseInt(target.dataset.index, 10); 260 | 261 | this.state.data.externaljs.splice(index, 1); 262 | this.setState({ data: this.state.data }); 263 | } 264 | 265 | @bind 266 | private _onExternaljsChanged(e: Event) 267 | { 268 | const target = e.target as HTMLInputElement; 269 | const index = parseInt(target.dataset.index, 10); 270 | 271 | this.state.data.externaljs[index].url = target.value; 272 | } 273 | 274 | @bind 275 | private _onVersionChange(versionTypeStr: string) 276 | { 277 | const data = this.state.data; 278 | const versionType: PixiVersionType = parseInt(versionTypeStr, 10); 279 | 280 | // Shouldn't be possible, but here as an extra careful check 281 | if (!(versionType in PixiVersionType)) 282 | return; 283 | 284 | switch (versionType) 285 | { 286 | case PixiVersionType.Release: 287 | data.pixiVersion = 'release'; 288 | break; 289 | 290 | case PixiVersionType.Tag: 291 | data.pixiVersion = this.state.versionOptions[0] || 'release'; 292 | break; 293 | 294 | case PixiVersionType.Custom: 295 | data.pixiVersion = ''; 296 | break; 297 | 298 | default: 299 | assertNever(versionType); 300 | } 301 | 302 | this.setState({ data, versionType }); 303 | } 304 | 305 | @bind 306 | private _onToggle(evt: MouseEvent) 307 | { 308 | const data = this.state.data; 309 | 310 | switch ((evt.target as HTMLInputElement).name) 311 | { 312 | case 'public': 313 | data.isPublic = !data.isPublic; 314 | this.setState({ data }); 315 | break; 316 | 317 | case 'autoUpdate': 318 | data.autoUpdate = !data.autoUpdate; 319 | this.setState({ data }); 320 | break; 321 | } 322 | } 323 | 324 | @bind 325 | private _onSaveClick(e: Event) 326 | { 327 | e.preventDefault(); 328 | if (this.props.onSaveClick) 329 | this.props.onSaveClick(this.state.data); 330 | } 331 | 332 | @bind 333 | private _onCloseClick() 334 | { 335 | if (this.props.onCloseClick) 336 | this.props.onCloseClick(); 337 | } 338 | } 339 | -------------------------------------------------------------------------------- /client/src/pages/Editor.tsx: -------------------------------------------------------------------------------- 1 | // TODO: When on mobile, hide the editor (don't even load it). Just show the result demo. 2 | // Monaco Editor is not supported on mobile, but I still want people to be able to see the demos. 3 | 4 | import * as monaco from 'monaco-editor/esm/vs/editor/editor.api'; 5 | import { h, Component } from 'preact'; 6 | import { route } from 'preact-router'; 7 | import { bind } from 'decko'; 8 | import { getPlayground, getTypings, updatePlayground, createPlayground } from '../service'; 9 | import { IPageProps } from './IPageProps'; 10 | import { MonacoEditor } from '../components/MonacoEditor'; 11 | import { EditorTopBar } from '../components/EditorTopBar'; 12 | import { EditorSettingsDialog } from '../components/EditorSettingsDialog'; 13 | import { IPlayground } from '../../../shared/types'; 14 | 15 | interface IProps extends IPageProps 16 | { 17 | slug?: string; 18 | } 19 | 20 | type TAlertType = 'success'|'info'|'warning'|'error'; 21 | 22 | const alertShowTime = 4000; 23 | let activePixiTypings: monaco.IDisposable = null; 24 | 25 | interface IEditorState 26 | { 27 | splitAmount: number; 28 | } 29 | 30 | interface IState 31 | { 32 | loadingFlags: number; 33 | saving: boolean; 34 | showSettings: boolean; 35 | dirty: boolean; 36 | oldPixiVersion: string; 37 | data: IPlayground; 38 | alert: { type: TAlertType, msg: string, timeout: number, show: boolean }; 39 | editorState: IEditorState; 40 | splitterIsDragged: boolean; 41 | isMobile: boolean; 42 | isEditor: boolean; 43 | } 44 | 45 | enum LoadingFlag 46 | { 47 | Playground = (1 << 0), 48 | Editor = (1 << 1), 49 | Typings = (1 << 2), 50 | 51 | All = Playground | Editor | Typings, 52 | } 53 | 54 | export class Editor extends Component 55 | { 56 | private _splitter: Element; 57 | private _splitterOverlay: Element; 58 | private _editorInstance: monaco.editor.IStandaloneCodeEditor; 59 | private _dialogInstance: EditorSettingsDialog; 60 | private _resultIFrame: HTMLIFrameElement; 61 | private _onChangeDelay: number; 62 | private _onChangeTimer: number; 63 | private _onSaveTimer: number; 64 | private _loadingFlags: number; 65 | 66 | constructor(props: IProps, context: any) 67 | { 68 | super(props, context); 69 | 70 | this._splitter = null; 71 | this._editorInstance = null; 72 | this._resultIFrame = null; 73 | this._onChangeDelay = 1000; 74 | this._onChangeTimer = 0; 75 | 76 | const isMobile = !!window.matchMedia("only screen and (max-width: 540px)").matches; 77 | 78 | this._loadingFlags = LoadingFlag.All; 79 | 80 | if (!this.props.slug) 81 | this._loadingFlags &= ~LoadingFlag.Playground; 82 | 83 | this.state = { 84 | loadingFlags: this._loadingFlags, 85 | saving: false, 86 | showSettings: false, 87 | dirty: true, 88 | oldPixiVersion: 'release', 89 | data: { 90 | pixiVersion: 'release', 91 | isPublic: true, 92 | autoUpdate: true, 93 | }, 94 | alert: { 95 | type: 'info', 96 | msg: '', 97 | timeout: 0, 98 | show: false, 99 | }, 100 | editorState: { 101 | splitAmount: 50 102 | }, 103 | splitterIsDragged: false, 104 | isMobile: isMobile, 105 | isEditor: false 106 | }; 107 | 108 | this.loadEditorConfig(); 109 | 110 | this.loadPlayground(); 111 | } 112 | 113 | setLoading(flag: LoadingFlag, loading: boolean): void 114 | { 115 | if (loading) 116 | this._loadingFlags |= flag; 117 | else 118 | this._loadingFlags &= ~flag; 119 | 120 | this.setState({ loadingFlags: this._loadingFlags }); 121 | } 122 | 123 | isLoading(flag: LoadingFlag): boolean 124 | { 125 | return (this._loadingFlags & flag) === flag; 126 | } 127 | 128 | componentDidUpdate(props: IProps, state: IState) 129 | { 130 | clearTimeout(this._onSaveTimer); 131 | this._onSaveTimer = window.setTimeout(()=> this.saveEditorConfig(), 300); 132 | } 133 | 134 | componentWillMount() 135 | { 136 | window.addEventListener('keydown', this._onKeydown); 137 | window.onbeforeunload = this._onBeforeUnload; 138 | } 139 | 140 | componentWillUnmount() 141 | { 142 | window.removeEventListener('keydown', this._onKeydown); 143 | 144 | if (this._splitter) 145 | this._splitter.removeEventListener('pointerdown', this._onSplitterDown); 146 | 147 | window.onbeforeunload = null; 148 | } 149 | 150 | loadPlayground() 151 | { 152 | if (!this.props.slug) 153 | { 154 | this.onEditorValueChange(this._editorInstance ? this._editorInstance.getValue() : ''); 155 | } 156 | else 157 | { 158 | getPlayground(this.props.slug, (err, data) => 159 | { 160 | if (err) 161 | { 162 | this.setLoading(LoadingFlag.Playground, false); 163 | route(`/edit`); 164 | this._showAlert('error', err.message); 165 | } 166 | else 167 | { 168 | this.setLoading(LoadingFlag.Playground, false); 169 | this.setState({ data }); 170 | } 171 | 172 | if (this._dialogInstance) 173 | this._dialogInstance.updatePlaygroundData(data); 174 | 175 | this.onEditorValueChange( 176 | data.contents || (this._editorInstance ? this._editorInstance.getValue() : '') 177 | ); 178 | }); 179 | } 180 | } 181 | 182 | loadTypings() 183 | { 184 | const version = this.state.data.pixiVersion; 185 | 186 | this.setLoading(LoadingFlag.Typings, true); 187 | getTypings(version, (typings) => 188 | { 189 | if (typings) 190 | this.enableTypings(typings); 191 | 192 | this.setLoading(LoadingFlag.Typings, false); 193 | this.onEditorValueChange(this._editorInstance.getValue()); 194 | }); 195 | } 196 | 197 | enableTypings(typings: string) 198 | { 199 | if (activePixiTypings) 200 | activePixiTypings.dispose(); 201 | 202 | const jsDefaults = monaco.languages.typescript.javascriptDefaults; 203 | 204 | activePixiTypings = jsDefaults.addExtraLib(typings, 'pixi.js.d.ts'); 205 | } 206 | 207 | loadEditorConfig() 208 | { 209 | const data = JSON.parse(localStorage.getItem("editorState")) as IEditorState; 210 | 211 | if (!data) 212 | return; 213 | 214 | this.state.editorState.splitAmount = data.splitAmount || 50; 215 | } 216 | 217 | saveEditorConfig() 218 | { 219 | const data = this.state.editorState; 220 | localStorage.setItem("editorState" , JSON.stringify(data)) 221 | } 222 | 223 | @bind 224 | updateDemo() 225 | { 226 | if (this._isLoadingAny() || !this._resultIFrame || !this._resultIFrame.contentWindow) 227 | return; 228 | 229 | this._resultIFrame.contentWindow.location.reload(); 230 | } 231 | 232 | @bind 233 | onSplitterMounded(splitter: Element) 234 | { 235 | if (!this._splitter) 236 | this._splitter = splitter; 237 | 238 | this._splitter.addEventListener("pointerdown",this._onSplitterDown); 239 | } 240 | 241 | @bind 242 | _onSplitterDown(event: PointerEvent) 243 | { 244 | this.setState({ 245 | splitterIsDragged: true 246 | }); 247 | 248 | this._splitterOverlay.addEventListener("pointermove", this._onSplitterMove); 249 | this._splitterOverlay.addEventListener("pointercancel", this._onSplitterReleased); 250 | this._splitterOverlay.addEventListener("pointerout", this._onSplitterReleased); 251 | this._splitterOverlay.addEventListener("pointerup", this._onSplitterReleased); 252 | } 253 | 254 | @bind 255 | _onSplitterReleased(event: PointerEvent) 256 | { 257 | this.setState({ 258 | splitterIsDragged: false 259 | }); 260 | 261 | this._splitterOverlay.removeEventListener("pointermove", this._onSplitterMove); 262 | this._splitterOverlay.removeEventListener("pointercancel", this._onSplitterReleased); 263 | this._splitterOverlay.removeEventListener("pointerout", this._onSplitterReleased); 264 | this._splitterOverlay.removeEventListener("pointerup", this._onSplitterReleased); 265 | } 266 | 267 | @bind 268 | _onSplitterMove(event: PointerEvent) 269 | { 270 | const width = this._splitterOverlay.clientWidth; 271 | const x = event.clientX; 272 | this.setState({ editorState: { splitAmount: 100 * x / width} }); 273 | } 274 | 275 | @bind 276 | onSettingsMount(dialog: EditorSettingsDialog) 277 | { 278 | this._dialogInstance = dialog; 279 | dialog.updatePlaygroundData(this.state.data); 280 | } 281 | 282 | @bind 283 | onEditorMount(editor: monaco.editor.IStandaloneCodeEditor, model: monaco.editor.ITextModel) 284 | { 285 | this._editorInstance = editor; 286 | 287 | this.loadTypings(); 288 | 289 | this.setLoading(LoadingFlag.Editor, false); 290 | this.onEditorValueChange(editor.getValue()); 291 | } 292 | 293 | @bind 294 | onResultIFrameMount(iframe: HTMLIFrameElement) 295 | { 296 | if (this._resultIFrame) 297 | this._resultIFrame.removeEventListener('load', this._onResultIFrameLoaded, false); 298 | 299 | this._resultIFrame = iframe; 300 | 301 | iframe.addEventListener('load', this._onResultIFrameLoaded, false); 302 | } 303 | 304 | @bind 305 | onEditorValueChange(newValue: string) 306 | { 307 | if (this._isLoadingAny()) 308 | return; 309 | 310 | this.state.data.contents = newValue; 311 | 312 | this.setState({ dirty: true }); 313 | 314 | clearTimeout(this._onChangeTimer); 315 | if (this.state.data.autoUpdate) 316 | { 317 | this._onChangeTimer = window.setTimeout(() => 318 | { 319 | this.updateDemo(); 320 | }, this._onChangeDelay); 321 | } 322 | } 323 | 324 | render(props: IProps, state: IState) 325 | { 326 | return ( 327 |
328 |
329 | 330 | {state.alert.msg} 331 | 332 |
333 |
334 |
    335 | {this._renderLoadingInfoItem(this.isLoading(LoadingFlag.Playground), 'Playground data')} 336 | {this._renderLoadingInfoItem(this.isLoading(LoadingFlag.Editor), 'Monaco editor')} 337 | {this._renderLoadingInfoItem(this.isLoading(LoadingFlag.Typings), 'PixiJS types')} 338 |
339 |
340 | 346 | 355 |
356 |
359 | 374 |
375 |
378 |