Migrate to Next.js + big style update (#154)

* Backend changes to diff from label rather than 0, also possibly fix #109

* implement diff_label frontend & fix assemble_asm

* Log stack trace if asm-differ fails

* don't return { obj } from api

* slight change in stub func code

* GET /compilers returns arches for compilers

* GET /compilers returns arches for compilers

* keep compiler_ids

* Obtain ido from download script, add comment for permuter api support

* Clean m2c wrapper code, add left pointer style, add test

* fix 3 tests

* list arches from api

* fix cookies in DEBUG

SameSite=None is incompatible with Secure, and this causes some browsers
to ignore the cookie altogether.

* fix test

Regression due to changing API to not return { "user": User } but
rather just the User object itself.

* fix create scratch without glabel

This works around a backend bug

* add label select on scratch creation

* show compilers/presets for current arch only

- fixes #92
- fixes #132

* fix mypy issues

* use react instead of preact

* hold pages in src/pages/

This matches NextJS file structure; the names of files reflect their routing paths.

* migrate to NextJS

* use next-pwa

* statically generate user page

* fix github login

* add loading progress bar

* arch on scratch

* nav redesign

* use .env.local instead of local.env

* fix unset compiler not considering arch

* add discord server to readme

* big styling update

* add footer

* move compiler dir into components

* use lib dir

* fix ts error

* describe deployment

* dont use NEXT_PUBLIC_* in .env

* add storybook

* document storybook

* make AsyncButton loading state pretty

* remove sharp

* custom monaco editor react component

* ci: build frontend

* allow nextjs to build despite ignored typescript errors

* ci

* remove react-loading-skeleton

* oops

* don't request public_repo github scope

* give AsyncButton error popup its arrow back

* try fix monaco problem

* oops

* fix monaco red bg for real this time

Co-authored-by: Ethan Roseman <ethteck@gmail.com>
This commit is contained in:
alex
2021-10-12 16:49:23 +01:00
committed by GitHub
parent 4e8b07efaa
commit 7757386396
101 changed files with 12237 additions and 3178 deletions
+8 -1
View File
@@ -17,7 +17,7 @@ jobs:
- run: reviewdog -reporter=github-check
env:
REVIEWDOG_GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }}
django_test:
name: django test
runs-on: ubuntu-latest
@@ -32,3 +32,10 @@ jobs:
- run: python backend/manage.py test backend
env:
SYSTEM_ENV: GITHUB_WORKFLOW
now_build:
name: build frontend
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- run: cd frontend && yarn && yarn lint && yarn build && yarn build-storybook
+5
View File
@@ -10,3 +10,8 @@ sandbox/
.DS_Store
.env.*
*.log
/frontend/.next
/frontend/.cache
/frontend/public/sw.*
/frontend/public/workbox-*
/frontend/storybook-static
+12 -1
View File
@@ -1,6 +1,17 @@
{
"files.trimTrailingWhitespace": true,
// eslint
"eslint.workingDirectories": [
"frontend"
],
"files.trimTrailingWhitespace": true,
"eslint.format.enable": true,
"[typescriptreact]": {
"editor.defaultFormatter": "dbaeumer.vscode-eslint",
"editor.formatOnSave": true,
},
"[typescript]": {
"editor.defaultFormatter": "dbaeumer.vscode-eslint",
"editor.formatOnSave": true,
},
}
+25 -3
View File
@@ -1,6 +1,11 @@
# [decomp.me](https://decomp.me)
A collaborative decompilation and reverse engineering website, built with React and Django.
[![Discord Server][discord-badge]][discord]
[discord]: https://discord.gg/sutqNShRRs
[discord-badge]: https://img.shields.io/discord/897066363951128586?color=%237289DA&logo=discord&logoColor=ffffff
A collaborative decompilation and reverse engineering website, built with Next.js and Django.
## Directory structure
```
@@ -46,7 +51,7 @@ yarn
- Start the development webserver
```shell
yarn start
yarn dev
```
- Access the site via [http://localhost:8080](http://localhost:8080)
@@ -182,9 +187,20 @@ To enable it locally outside of the Docker container:
- Set `USE_SANDBOX_JAIL=on`
- Set `SANDBOX_NSJAIL_BIN_PATH` to the absolute path of the `nsjail` binary built above
## Deployment
- Backend - same as in development, just set DEBUG=true
- Frontend - multiple options:
- Self-hosted - `yarn build && yarn start` with nginx proxy to filter /api/* to the backend
- [Deploy with Vercel](https://vercel.com/new)
## Contributing
Contributions are very much welcome! We have a Discord channel in the [Zelda Decompilation server](http://discord.zelda64.dev).
Contributions are very much welcome! You may want to [join our Discord server](https://discord.gg/sutqNShRRs).
### Storybook
Use `yarn storybook` to run a Storybook instance on [http://localhost:6006](http://localhost:6006). This is useful for testing UI components in isolation.
### Linting
@@ -194,6 +210,12 @@ cd frontend
yarn lint
```
- Autofix frontend
```shell
cd frontend
yarn lint --fix
```
- Check backend
```shell
cd backend
+2 -2
View File
@@ -90,8 +90,8 @@ class GitHubUser(models.Model):
raise MalformedGithubApiResponse()
scopes = scope_str.split(",")
if not "public_repo" in scopes:
raise MissingOAuthScope("public_repo")
#if not "public_repo" in scopes:
# raise MissingOAuthScope("public_repo")
details = Github(access_token).get_user()
+2 -2
View File
@@ -160,5 +160,5 @@ SANDBOX_NSJAIL_BIN_PATH = Path(env("SANDBOX_NSJAIL_BIN_PATH"))
SANDBOX_CHROOT_PATH = BASE_DIR.parent / "sandbox" / "root"
SANDBOX_TMP_PATH = BASE_DIR.parent / "sandbox" / "tmp"
GITHUB_CLIENT_ID = env("GITHUB_CLIENT_ID")
GITHUB_CLIENT_SECRET = env("GITHUB_CLIENT_SECRET")
GITHUB_CLIENT_ID = env("GITHUB_CLIENT_ID", str)
GITHUB_CLIENT_SECRET = env("GITHUB_CLIENT_SECRET", str)
-29
View File
@@ -1,29 +0,0 @@
module.exports = {
"parser": "@typescript-eslint/parser",
"plugins": [
"@typescript-eslint",
],
"extends": [
"eslint:recommended",
"preact",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended",
],
"rules": {
"semi": ["error", "never", { "beforeStatementContinuationChars": "always" }],
"indent": ["error", 4],
"quotes": ["error", "double"],
"quote-props": ["error", "consistent"],
"brace-style": "error",
"object-curly-spacing": ["error", "always"],
"array-bracket-spacing": ["error", "never"],
"no-else-return": "off",
"no-trailing-spaces": "error",
"prefer-const": ["warn", { destructuring: "all" }],
"arrow-parens": ["error", "as-needed"],
"no-confusing-arrow": ["error", { allowParens: true }],
"@typescript-eslint/explicit-module-boundary-types": "off",
"@typescript-eslint/ban-ts-comment": "off",
"react/no-danger": "off",
},
}
+55
View File
@@ -0,0 +1,55 @@
{
"extends": "next/core-web-vitals",
"rules": {
"semi": ["error", "never", { "beforeStatementContinuationChars": "always" }],
"indent": ["error", 4],
"quotes": ["error", "double"],
"quote-props": ["error", "consistent"],
"brace-style": "error",
"object-curly-spacing": ["error", "always"],
"array-bracket-spacing": ["error", "never"],
"no-else-return": "off",
"no-trailing-spaces": "error",
"prefer-const": ["warn", { "destructuring": "all" }],
"arrow-parens": ["error", "as-needed"],
"no-confusing-arrow": ["error", { "allowParens": true }],
"import/newline-after-import": "error",
"import/no-duplicates": "error",
"import/first": "error",
"import/exports-last": "warn",
"import/order": [
"error",
{
"groups": ["builtin", "external", "internal", "parent", "sibling", "index", "object"],
"newlines-between": "always",
"pathGroups": [
{
"pattern": "react",
"group": "builtin",
"position": "before"
},
{
"pattern": "next",
"group": "builtin",
"position": "after"
},
{
"pattern": "next/*",
"group": "builtin",
"position": "after"
}
],
"pathGroupsExcludedImportTypes": ["react"],
"alphabetize": { "order": "asc", "caseInsensitive": true }
}
]
},
"overrides": [
{
"files": ["*.stories.tsx"],
"rules": {
"import/exports-last": "off"
}
}
]
}
+64
View File
@@ -0,0 +1,64 @@
const path = require("path")
const { config } = require("dotenv")
const { execSync } = require("child_process")
const MonacoWebpackPlugin = require("monaco-editor-webpack-plugin")
for (const envFile of [".env.local", ".env"]) {
config({ path: `../${envFile}` })
}
process.env.STORYBOOK_API_BASE = process.env.API_BASE
process.env.STORYBOOK_GITHUB_CLIENT_ID = process.env.GITHUB_CLIENT_ID
process.env.STORYBOOK_COMMIT_HASH = execSync("git rev-parse HEAD").toString().trim()
module.exports = {
stories: [
"../src/**/*.stories.mdx",
"../src/**/*.stories.@(js|jsx|ts|tsx)",
],
addons: [
"@storybook/addon-links",
"@storybook/addon-essentials",
"@storybook/addon-viewport",
"storybook-dark-mode",
],
core: {
builder: "webpack5",
},
webpackFinal: async (config, { configType }) => {
// `configType` has a value of 'DEVELOPMENT' or 'PRODUCTION'
// You can change the configuration based on that.
// 'PRODUCTION' is used when building the static version of storybook.
// load svg as element
const fileLoaderRule = config.module.rules.find(rule => rule.test && rule.test.test('.svg'));
fileLoaderRule.exclude = /\.svg$/
config.module.rules.push({
test: /\.svg$/,
use: ["@svgr/webpack"],
})
// run postcss on scss
config.module.rules.push({
test: /\.scss$/,
use: [
'style-loader', {
loader: 'css-loader',
options: {
importLoaders: 2,
},
},
'postcss-loader',
'sass-loader',
],
include: path.resolve(__dirname, '../src'),
})
config.plugins.push(new MonacoWebpackPlugin({
languages: [],
filename: "[name].worker.[contenthash].js",
}))
return config
},
}
+20
View File
@@ -0,0 +1,20 @@
//import styles from "../src/pages/_app.scss"
import "!style-loader!css-loader!sass-loader!../src/pages/_app.scss"
export const decorators = [
Story => {
document.body.classList.add("themePlum")
return <Story />
},
]
export const parameters = {
actions: { argTypesRegex: "^on[A-Z].*" },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/,
},
},
}
+1 -1
View File
@@ -2,4 +2,4 @@
yarn install --frozen-lockfile
yarn start
yarn dev
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/types/global" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.
+71
View File
@@ -0,0 +1,71 @@
const { config } = require("dotenv")
const { execSync } = require("child_process")
const MonacoWebpackPlugin = require("monaco-editor-webpack-plugin")
const removeImports = require("next-remove-imports")({
//test: /node_modules([\s\S]*?)\.(tsx|ts|js|mjs|jsx)$/,
//matchImports: "\\.(less|css|scss|sass|styl)$"
})
for (const envFile of [".env.local", ".env"]) {
config({ path: `../${envFile}` })
}
process.env.NEXT_PUBLIC_API_BASE = process.env.API_BASE
process.env.NEXT_PUBLIC_GITHUB_CLIENT_ID = process.env.GITHUB_CLIENT_ID
process.env.NEXT_PUBLIC_COMMIT_HASH = execSync("git rev-parse HEAD").toString().trim()
const withPWA = require('next-pwa')
const runtimeCaching = require('next-pwa/cache')
module.exports = removeImports(withPWA({
async redirects() {
return [
{
source: "/",
destination: "/scratch",
permanent: false,
},
]
},
async rewrites() {
return []
},
async headers() {
return [
{
source: "/(.*)", // all routes
headers: [
{
key: "X-DNS-Prefetch-Control",
value: "on"
},
],
},
]
},
webpack(config) {
config.module.rules.push({
test: /\.svg$/,
use: ["@svgr/webpack"],
})
config.plugins.push(new MonacoWebpackPlugin({
languages: [],
filename: "[name].worker.[contenthash].js",
}))
return config
},
images: {
domains: ["avatars.githubusercontent.com"],
},
pwa: {
dest: "public",
runtimeCaching,
disable: process.env.NODE_ENV === "development",
},
typescript: {
ignoreBuildErrors: true,
}
}))
+42 -17
View File
@@ -1,42 +1,67 @@
{
"license": "MIT",
"scripts": {
"start": "snowpack dev --polyfill-node",
"build": "snowpack build --polyfill-node",
"lint": "tsc && eslint src --ext .js,.jsx,.ts,.tsx"
"dev": "next dev --port 8080",
"build": "next build",
"start": "next start --port 8080",
"lint": "next lint",
"postinstall": "next telemetry disable > /dev/null",
"storybook": "start-storybook -p 6006 --ci",
"build-storybook": "build-storybook"
},
"dependencies": {
"@monaco-editor/react": "^4.2.2",
"@badrap/bar-of-progress": "^0.1.2",
"@primer/octicons-react": "^15.0.0",
"@react-hook/resize-observer": "^1.2.2",
"classnames": "^2.3.1",
"dequal": "^2.0.2",
"framer-motion": "^4.1.17",
"monaco-editor": "^0.27.0",
"preact": "^10.5.13",
"is-mobile": "^3.0.0",
"monaco-editor": "^0.29.1",
"next": "^11.1.2",
"next-pwa": "^5.3.1",
"react": "^18.0.0-alpha-fd5e01c2e-20210913",
"react-dom": "^18.0.0-alpha-fd5e01c2e-20210913",
"react-hot-toast": "^2.1.0",
"react-laag": "^2.0.3",
"react-loading-skeleton": "^2.2.0",
"react-router-dom": "^5.2.0",
"react-simple-resizer": "^2.1.0",
"sass": "^1.42.1",
"swr": "^1.0.0",
"use-debounce": "^7.0.0",
"use-deep-compare-effect": "^1.6.1"
},
"devDependencies": {
"@prefresh/snowpack": "^3.0.0",
"@snowpack/plugin-postcss": "^1.4.3",
"@types/react-router-dom": "^5.1.8",
"@typescript-eslint/eslint-plugin": "^4.29.3",
"@typescript-eslint/parser": "^4.29.3",
"@babel/core": "^7.15.8",
"@next/eslint-plugin-next": "^11.1.2",
"@storybook/addon-actions": "^6.3.10",
"@storybook/addon-essentials": "^6.3.10",
"@storybook/addon-links": "^6.3.10",
"@storybook/addon-postcss": "^2.0.0",
"@storybook/addon-viewport": "^6.3.10",
"@storybook/builder-webpack5": "^6.3.10",
"@storybook/manager-webpack5": "^6.3.10",
"@storybook/preset-scss": "^1.0.3",
"@storybook/react": "^6.3.10",
"@svgr/webpack": "^5.5.0",
"autoprefixer": "^10.3.1",
"babel-loader": "^8.2.2",
"css-loader": "^6.4.0",
"cssnano": "^5.0.7",
"dotenv": "^10.0.0",
"eslint": "^7.32.0",
"eslint-config-preact": "^1.1.4",
"eslint": "7",
"eslint-config-next": "11.1.2",
"eslint-formatter-rdjson": "^1.0.5",
"eslint-plugin-react": "^7.26.1",
"eslint-plugin-react-hooks": "^4.2.0",
"monaco-editor-webpack-plugin": "^5.0.0",
"next-remove-imports": "^1.0.6",
"postcss": "^8.3.6",
"postcss-loader": "^6.1.1",
"postcss-scrollbar": "^0.3.0",
"snowpack": "^3.8.8",
"typescript": "^4.4.2"
"sass-loader": "^12.1.0",
"storybook-dark-mode": "^1.0.8",
"style-loader": "^3.3.0",
"typescript": "^4.4.2",
"webpack": "5"
}
}
+3 -3
View File
@@ -1,7 +1,7 @@
module.exports = {
plugins: [
require("postcss-scrollbar"),
require("autoprefixer"),
require("cssnano"),
"postcss-scrollbar",
"autoprefixer",
"cssnano",
],
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

-24
View File
@@ -1,24 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content="Crowdsourced decompilation website" />
<link rel="stylesheet" type="text/css" href="/dist/global.css" />
<link rel="shortcut icon" type="image/png" href="/frog.png" />
<link rel="apple-touch-icon" type="image/png" href="/frog.png" />
<title>decomp.me</title>
</head>
<body>
<div id="root"></div>
<noscript>
Please enable JavaScript to use decomp.me.
</noscript>
<script type="module" src="/dist/index.js"></script>
</body>
</html>
+23
View File
@@ -0,0 +1,23 @@
{
"name": "decomp.me",
"short_name": "decomp.me",
"description": "Decompile code in the browser",
"theme_color": "#951fd9",
"background_color": "#292f33",
"display": "minimal-ui",
"orientation": "any",
"scope": "/",
"start_url": "/",
"icons": [
{
"src": "purplefrog.svg",
"sizes": "48x48 320x320",
"purpose": "monochrome"
},
{
"src": "purplefrog-bg.svg",
"sizes": "48x48 320x320",
"purpose": "maskable"
}
]
}
+117
View File
@@ -0,0 +1,117 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
viewBox="0 0 42 42"
version="1.1"
id="svg1025"
sodipodi:docname="purplefrog-bg.svg"
inkscape:version="1.1 (c4e8f9e, 2021-05-24)"
width="42"
height="42"
inkscape:export-filename="/Users/alex/bitmap.png"
inkscape:export-xdpi="685.71002"
inkscape:export-ydpi="685.71002"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/">
<defs
id="defs1029" />
<sodipodi:namedview
id="namedview1027"
pagecolor="#505050"
bordercolor="#eeeeee"
borderopacity="1"
inkscape:pageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="0"
showgrid="false"
inkscape:zoom="7.044923"
inkscape:cx="20.014413"
inkscape:cy="31.795947"
inkscape:window-width="1312"
inkscape:window-height="918"
inkscape:window-x="0"
inkscape:window-y="25"
inkscape:window-maximized="0"
inkscape:current-layer="svg1025"
width="42px" />
<title
id="title1007">decomp.me</title>
<rect
style="fill:#292f33;fill-opacity:1;stroke-width:1.2717"
id="rect1392"
width="42"
height="42"
x="0"
y="0" />
<g
id="g1513"
transform="matrix(0.9053692,0,0,0.92459108,4.7033544,4.3573606)">
<path
fill="var(--frog-secondary)"
d="M 36,22 C 36,29.456 27.941,34 18,34 8.059,34 0,29.456 0,22 0,14.544 8.059,7 18,7 c 9.941,0 18,7.544 18,15 z"
id="path1009"
style="fill:#cc87f4;fill-opacity:1" />
<path
fill="var(--frog-primary)"
d="M 31.755,12.676 C 33.123,11.576 34,9.891 34,8 34,4.687 31.313,2 28,2 25.139,2 22.75,4.004 22.149,6.685 20.861,6.202 19.466,5.927 18,5.927 16.535,5.927 15.139,6.202 13.851,6.685 13.25,4.004 10.861,2 8,2 4.687,2 2,4.687 2,8 2,9.891 2.877,11.576 4.245,12.676 1.6,15.356 0,18.685 0,22 c 0,7.456 8.059,1 18,1 9.941,0 18,6.456 18,-1 0,-3.315 -1.6,-6.644 -4.245,-9.324 z"
id="path1011"
style="fill:#951fd9;fill-opacity:1" />
<circle
fill="#ffffff"
cx="7.5"
cy="7.5"
r="3.5"
class="eyeL"
id="circle1013" />
<circle
fill="var(--frog-pupil)"
cx="7.5"
cy="7.5"
r="1.5"
class="pupilL"
id="circle1015"
style="fill:#292f33;fill-opacity:1" />
<circle
fill="#ffffff"
cx="28.5"
cy="7.5"
r="3.5"
class="eyeR"
id="circle1017" />
<circle
fill="var(--frog-pupil)"
cx="28.5"
cy="7.5"
r="1.5"
class="pupilR"
id="circle1019"
style="fill:#292f33;fill-opacity:1" />
<circle
fill="var(--frog-nose)"
cx="14"
cy="20"
r="1"
id="circle1021"
style="fill:#561e77;fill-opacity:1" />
<circle
fill="var(--frog-nose)"
cx="22"
cy="20"
r="1"
id="circle1023"
style="fill:#561e77;fill-opacity:1" />
</g>
<metadata
id="metadata1355">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:title>decomp.me</dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
</svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

+100
View File
@@ -0,0 +1,100 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
viewBox="0 0 36 36"
version="1.1"
id="svg1025"
sodipodi:docname="purplefrog.svg"
inkscape:version="1.1 (c4e8f9e, 2021-05-24)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/">
<defs
id="defs1029" />
<sodipodi:namedview
id="namedview1027"
pagecolor="#505050"
bordercolor="#eeeeee"
borderopacity="1"
inkscape:pageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="0"
showgrid="false"
inkscape:zoom="19.222222"
inkscape:cx="17.973988"
inkscape:cy="18"
inkscape:window-width="1312"
inkscape:window-height="918"
inkscape:window-x="0"
inkscape:window-y="25"
inkscape:window-maximized="0"
inkscape:current-layer="svg1025" />
<title
id="title1007">decomp.me</title>
<path
fill="var(--frog-secondary)"
d="M36 22c0 7.456-8.059 12-18 12S0 29.456 0 22 8.059 7 18 7s18 7.544 18 15z"
id="path1009"
style="fill:#cc87f4;fill-opacity:1" />
<path
fill="var(--frog-primary)"
d="M31.755 12.676C33.123 11.576 34 9.891 34 8c0-3.313-2.687-6-6-6-2.861 0-5.25 2.004-5.851 4.685-1.288-.483-2.683-.758-4.149-.758-1.465 0-2.861.275-4.149.758C13.25 4.004 10.861 2 8 2 4.687 2 2 4.687 2 8c0 1.891.877 3.576 2.245 4.676C1.6 15.356 0 18.685 0 22c0 7.456 8.059 1 18 1s18 6.456 18-1c0-3.315-1.6-6.644-4.245-9.324z"
id="path1011"
style="fill:#951fd9;fill-opacity:1" />
<circle
fill="#FFF"
cx="7.5"
cy="7.5"
r="3.5"
class="eyeL"
id="circle1013" />
<circle
fill="var(--frog-pupil)"
cx="7.5"
cy="7.5"
r="1.5"
class="pupilL"
id="circle1015"
style="fill:#292f33;fill-opacity:1" />
<circle
fill="#FFF"
cx="28.5"
cy="7.5"
r="3.5"
class="eyeR"
id="circle1017" />
<circle
fill="var(--frog-pupil)"
cx="28.5"
cy="7.5"
r="1.5"
class="pupilR"
id="circle1019"
style="fill:#292f33;fill-opacity:1" />
<circle
fill="var(--frog-nose)"
cx="14"
cy="20"
r="1"
id="circle1021"
style="fill:#561e77;fill-opacity:1" />
<circle
fill="var(--frog-nose)"
cx="22"
cy="20"
r="1"
id="circle1023"
style="fill:#561e77;fill-opacity:1" />
<metadata
id="metadata1355">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:title>decomp.me</dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

-33
View File
@@ -1,33 +0,0 @@
import { config } from "dotenv"
for (const envFile of [".env.local", ".env"]) {
config({ path: `../${envFile}` })
}
/** @type {import("snowpack").SnowpackUserConfig } */
export default {
env: {
DEBUG: process.env.DEBUG == "on",
API_BASE: process.env.API_BASE,
GITHUB_CLIENT_ID: process.env.GITHUB_CLIENT_ID,
},
mount: {
public: { url: "/", static: true },
src: { url: "/dist" },
},
plugins: [
"@prefresh/snowpack",
"@snowpack/plugin-postcss",
],
routes: [
/* Route everything to index.html */
{ match: "routes", src: ".*", dest: "/index.html" },
],
alias: {
"react": "preact/compat",
"react-dom": "preact/compat",
},
devOptions: {
open: "none",
}
}
-54
View File
@@ -1,54 +0,0 @@
import { h } from "preact"
import { BrowserRouter as Router, Route, Switch, Redirect } from "react-router-dom"
import { Toaster } from "react-hot-toast"
import { SkeletonTheme } from "react-loading-skeleton"
import NewScratchPage from "./scratch/NewScratch"
import ScratchPage from "./scratch/ScratchPage"
import LoginPage from "./user/LoginPage"
import UserPage from "./user/UserPage"
export default function App() {
return <SkeletonTheme color="#1c1e23" highlightColor="#26292d">
<Router>
<Switch>
<Route exact path="/">
<Redirect to="/scratch" />
</Route>
<Route exact path="/scratch">
<NewScratchPage />
</Route>
<Route path="/scratch/:slug">
<ScratchPage />
</Route>
<Route exact path="/login">
<LoginPage />
</Route>
<Route path="/~:username">
<UserPage />
</Route>
<Route>
{/* 404 */}
<Redirect to="/" />
</Route>
</Switch>
</Router>
<Toaster
position="bottom-center"
reverseOrder={true}
toastOptions={{
style: {
borderRadius: "100px",
background: "#333",
color: "#fff",
},
}}
/>
</SkeletonTheme>
}
-11
View File
@@ -1,11 +0,0 @@
.errorPopup {
font-size: 0.8rem;
background: #bb4444;
padding: 1rem;
border-radius: 0.4rem;
min-width: 6rem;
display: flex;
align-items: center;
justify-content: center;
}
-33
View File
@@ -1,33 +0,0 @@
.nav {
grid-area: nav; /* TODO remove */
user-select: none;
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
padding: 1em;
gap: .5em;
}
.logotype {
cursor: default;
color: #0afafa;
padding: 1em;
}
.grow {
flex: 1;
}
.link {
color: white;
font-weight: 700;
font-size: .85em;
}
.link:hover, .linkActive {
opacity: 0.8;
}
-27
View File
@@ -1,27 +0,0 @@
import { h } from "preact"
import { Link } from "react-router-dom"
import { PlusIcon } from "@primer/octicons-react"
import LoginState, { Props as LoginStateProps } from "./user/LoginState"
import styles from "./Nav.module.css"
export type Props = {
onUserChange?: LoginStateProps["onChange"],
}
export default function Nav({ onUserChange }: Props) {
return <nav class={styles.nav}>
<span class={styles.logotype}>
decomp.me
</span>
<Link className={styles.link} to="/scratch">
<PlusIcon size={16} /> New scratch
</Link>
<div class={styles.grow} />
<LoginState onChange={onUserChange} />
</nav>
}
-22
View File
@@ -1,22 +0,0 @@
import { ComponentChildren, h } from "preact"
import { ChevronDownIcon } from "@primer/octicons-react"
import styles from "./Select.module.css"
export type Props = {
class?: string,
onChange: h.JSX.GenericEventHandler<HTMLSelectElement>,
children: ComponentChildren,
}
export default function Select({ onChange, children, class: className }: Props) {
return <div class={`${styles.group} ${className}`}>
<select onChange={onChange}>
{children}
</select>
<div class={styles.icon}>
<ChevronDownIcon size={16} />
</div>
</div>
}
@@ -0,0 +1,50 @@
.errorPopup {
font-size: 0.8rem;
background: #bb4444;
color: var(--a800);
padding: 1rem;
border-radius: 0.4rem;
min-width: 6rem;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
}
.asyncBtn {
position: relative;
}
.label {
opacity: 1;
transform: initial;
transition: opacity .1s ease, transform .1s ease;
.isLoading & {
opacity: 0;
transform:scale(0.9);
}
}
.loading {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transform: scale(0.9);
transition: opacity .1s ease, transform .2s ease;
.isLoading & {
opacity: initial;
transform: scale(1);
}
}
@@ -0,0 +1,34 @@
import React from "react"
import { ComponentStory, ComponentMeta } from "@storybook/react"
import AsyncButton from "./AsyncButton"
export default {
title: "AsyncButton",
component: AsyncButton,
} as ComponentMeta<typeof AsyncButton>
const Template: ComponentStory<typeof AsyncButton> = args => <AsyncButton {...args} />
export const Success: ComponentStory<typeof AsyncButton> = Template.bind({})
Success.args = {
children: "Click me",
forceLoading: false,
errorPlacement: "right-center",
onClick: () => new Promise(resolve => setTimeout(() => resolve(undefined), 500)),
}
export const Loading: ComponentStory<typeof AsyncButton> = Template.bind({})
Loading.args = {
...Success.args,
forceLoading: true,
}
export const Error: ComponentStory<typeof AsyncButton> = Template.bind({})
Error.args = {
children: "Click me",
forceLoading: false,
errorPlacement: "right-center",
onClick: () => new Promise((_resolve, reject) => setTimeout(() => reject("I am error"), 500)),
}
@@ -1,27 +1,31 @@
import { h, ComponentChildren } from "preact"
import { useState, useCallback } from "preact/hooks"
import { useLayer, Arrow } from "react-laag"
import { ReactNode, useState, useCallback } from "react"
import classNames from "classnames"
import { motion, AnimatePresence } from "framer-motion"
import { useLayer, Arrow } from "react-laag"
import styles from "./AsyncButton.module.css"
import styles from "./AsyncButton.module.scss"
import Button, { Props as ButtonProps } from "./Button"
import LoadingSpinner from "./loading.svg"
export type Props = {
onPress: () => Promise<unknown>,
export interface Props extends ButtonProps {
onClick: () => Promise<unknown>,
forceLoading?: boolean,
disabled?: boolean,
children: ComponentChildren,
errorPlacement?: import("react-laag/dist/PlacementType").PlacementType,
children: ReactNode,
}
export default function AsyncButton({ onPress, disabled, forceLoading, children }: Props) {
export default function AsyncButton(props: Props) {
const [isAwaitingPromise, setIsAwaitingPromise] = useState(false)
const isLoading = isAwaitingPromise || forceLoading
const isLoading = isAwaitingPromise || props.forceLoading
const [errorMessage, setErrorMessage] = useState("")
const clickHandler = props.onClick
const onClick = useCallback(() => {
if (!disabled || isLoading) {
if (!isLoading) {
setIsAwaitingPromise(true)
setErrorMessage("")
const promise = onPress()
const promise = clickHandler()
if (promise instanceof Promise) {
promise.catch(error => {
@@ -31,26 +35,33 @@ export default function AsyncButton({ onPress, disabled, forceLoading, children
setIsAwaitingPromise(false)
})
} else {
console.error("AsyncButton onPress() must return a promise, but instead it returned", promise)
console.error("AsyncButton onClick() must return a promise, but instead it returned", promise)
setIsAwaitingPromise(false)
}
}
}, [disabled, isLoading, onPress])
}, [isLoading, clickHandler])
const { triggerProps, layerProps, arrowProps, renderLayer } = useLayer({
isOpen: errorMessage !== "",
onOutsideClick: () => setErrorMessage(""),
placement: "top-center",
placement: props.errorPlacement ?? "top-center",
triggerOffset: 8,
})
// TODO: prettier loading state
return <button
return <Button
{...props}
className={classNames(styles.asyncBtn, props.className, {
[styles.isLoading]: isLoading,
})}
onClick={onClick}
disabled={disabled}
{...triggerProps}
>
{isLoading ? "Loading..." : children}
<div className={styles.label}>
{props.children}
</div>
<div className={styles.loading}>
<LoadingSpinner width="24px" />
</div>
{renderLayer(
<AnimatePresence>
@@ -62,10 +73,10 @@ export default function AsyncButton({ onPress, disabled, forceLoading, children
transition={{ type: "spring", duration: 0.2 }}
{...layerProps}
>
<span>{errorMessage}</span>
<pre>{errorMessage}</pre>
<Arrow size={12} backgroundColor="#bb4444" {...arrowProps} />
</motion.div>}
</AnimatePresence>
)}
</button>
</Button>
}
@@ -0,0 +1,42 @@
.btn {
border-radius: 4px;
padding: .6em 1em;
font-size: .8rem;
user-select: none;
appearance: none;
display: inline-flex;
gap: .5em;
align-items: center;
background: transparent;
color: var(--a800);
border: 1px solid var(--g500);
transition: color .2s ease, background .15s ease, border-color .15s ease;
&:disabled {
opacity: 0.7;
}
&:not(:disabled) {
cursor: pointer;
&:hover {
color: var(--a900);
border-color: var(--g800);
}
}
}
.primary {
background: var(--accent);
color: var(--g2000);
border-color: var(--a200);
&:not(:disabled):hover {
border-color: var(--a600);
}
}
@@ -0,0 +1,46 @@
import React from "react"
import { BookIcon } from "@primer/octicons-react"
import { ComponentStory, ComponentMeta } from "@storybook/react"
import Button from "./Button"
export default {
title: "Button",
component: Button,
} as ComponentMeta<typeof Button>
const Template: ComponentStory<typeof Button> = args => <Button {...args} />
export const Default: ComponentStory<typeof Button> = Template.bind({})
Default.args = {
children: "Button",
primary: false,
disabled: false,
}
export const Disabled: ComponentStory<typeof Button> = Template.bind({})
Disabled.args = {
...Default.args,
disabled: true,
}
export const Primary: ComponentStory<typeof Button> = Template.bind({})
Primary.args = {
...Default.args,
primary: true,
}
export const PrimaryDisabled: ComponentStory<typeof Button> = Template.bind({})
PrimaryDisabled.args = {
...Default.args,
primary: true,
disabled: true,
}
export const Icon: ComponentStory<typeof Button> = args => (
<Button {...args}>
<BookIcon />
Read a book
</Button>
)
+38
View File
@@ -0,0 +1,38 @@
import { ForwardedRef, forwardRef } from "react"
import classNames from "classnames"
import styles from "./Button.module.scss"
const Button = forwardRef(function Button({
children,
onClick,
className,
disabled,
primary,
}: Props, ref: ForwardedRef<HTMLButtonElement>) {
return <button
ref={ref}
className={classNames(className, styles.btn, {
[styles.primary]: primary,
})}
onClick={event => {
if (!disabled) {
onClick(event)
}
}}
disabled={disabled}
>
{children}
</button>
})
export type Props = {
children: React.ReactNode,
onClick: (event: React.MouseEvent<HTMLButtonElement>) => void,
className?: string,
disabled?: boolean,
primary?: boolean,
}
export default Button
@@ -0,0 +1,26 @@
.editor {
flex: 1;
resize: none;
border: 0;
outline: none !important;
font-size: 12px;
font-family: monospace;
line-height: 1.5;
user-select: initial;
}
.loadingContainer {
display: flex;
align-items: center;
justify-content: center;
flex-grow: 1;
svg {
width: 24px;
height: 24px;
}
}
+61
View File
@@ -0,0 +1,61 @@
import { Suspense } from "react"
import dynamic from "next/dynamic"
import classNames from "classnames"
import mobile from "is-mobile"
import LoadingSpinner from "../loading.svg"
import styles from "./Editor.module.scss"
import type { Props as MonacoEditorProps } from "./MonacoEditor"
import getTheme from "./monacoTheme"
const isMobile = mobile()
interface Props extends MonacoEditorProps {
useLoadingSpinner?: boolean,
}
const MonacoEditor = isMobile ? null : dynamic(() => import("./MonacoEditor"), {
suspense: true, // @ts-ignore
})
// Wrapper component that asyncronously loads MonacoEditor on desktop,
// falling back to a simple textarea on mobile
export default function Editor(props: Props) {
const monacoTheme = getTheme()
const style = {
color: monacoTheme.colors["editor.foreground"],
backgroundColor: monacoTheme.colors["editor.background"],
padding: (props.padding ?? (props.showMargin ? 20 : 0)) + "px",
}
const textarea = <textarea
className={classNames(styles.editor, props.className)}
spellCheck={false}
value={props.value}
readOnly={!props.onChange}
onChange={event => {
const value = event.target.value
if (props.onChange)
props.onChange(value)
}}
style={style}
/>
if (MonacoEditor) {
const loading = props.useLoadingSpinner ? <div
className={classNames(styles.loadingContainer, props.className)}
style={style}
>
<LoadingSpinner />
</div> : textarea
return <Suspense fallback={loading}>
<MonacoEditor {...props} />
</Suspense>
} else {
return textarea
}
}
@@ -0,0 +1,26 @@
.container {
flex-grow: 1;
user-select: initial;
/* fix border-radius overflow */
overflow: hidden;
& :global(.monaco-editor),
& :global(.monaco-editor-background),
& :global(.margin),
& :global(.inputarea.ime-input) {
background-color: transparent !important;
}
/* weird box at top-left */
& :global(.monaco-hover) {
display: none;
}
}
.readonly {
cursor: text;
& :global(.cursor) {
display: none !important;
}
}
@@ -0,0 +1,94 @@
import { useState } from "react"
import { ComponentStory, ComponentMeta } from "@storybook/react"
import MonacoEditor from "./MonacoEditor"
export default {
title: "MonacoEditor",
component: MonacoEditor,
} as ComponentMeta<typeof MonacoEditor>
const Template: ComponentStory<typeof MonacoEditor> = args => {
const [value, setValue] = useState(args.value)
return <div style={{ display: "flex", width: "95vw", height: "95vh" }}>
<MonacoEditor {...args} value={value} onChange={setValue} />
</div>
}
export const C: ComponentStory<typeof MonacoEditor> = Template.bind({})
C.args = {
language: "c",
value:
`s32 collision_heap_free(void* data) {
if (gGameStatusPtr->isBattle) {
return _heap_free(&D_803DA800, data);
} else {
return _heap_free(&D_80268000, data);
}
}
`,
lineNumbers: true,
showMargin: true,
}
export const Mips: ComponentStory<typeof MonacoEditor> = Template.bind({})
Mips.args = {
language: "mips",
value:
`.set noat # allow manual use of $at
.set noreorder # don't insert nops after branches
glabel sins
/* 3F9F0 800645F0 3084FFFF */ andi $a0, $a0, 0xffff
/* 3F9F4 800645F4 00042102 */ srl $a0, $a0, 4
/* 3F9F8 800645F8 30820400 */ andi $v0, $a0, 0x400
/* 3F9FC 800645FC 10400004 */ beqz $v0, .L80064610
/* 3FA00 80064600 00802821 */ addu $a1, $a0, $zero
/* 3FA04 80064604 00041027 */ nor $v0, $zero, $a0
/* 3FA08 80064608 08019185 */ j .L80064614
/* 3FA0C 8006460C 304203FF */ andi $v0, $v0, 0x3ff
.L80064610:
/* 3FA10 80064610 308203FF */ andi $v0, $a0, 0x3ff
.L80064614:
/* 3FA14 80064614 00021040 */ sll $v0, $v0, 1
/* 3FA18 80064618 3C038009 */ lui $v1, %hi(sintable)
/* 3FA1C 8006461C 00621821 */ addu $v1, $v1, $v0
/* 3FA20 80064620 94633DE0 */ lhu $v1, %lo(sintable)($v1)
/* 3FA24 80064624 30A20800 */ andi $v0, $a1, 0x800
/* 3FA28 80064628 14400003 */ bnez $v0, .L80064638
/* 3FA2C 8006462C 00031023 */ negu $v0, $v1
/* 3FA30 80064630 0801918F */ j .L8006463C
/* 3FA34 80064634 00031400 */ sll $v0, $v1, 0x10
.L80064638:
/* 3FA38 80064638 00021400 */ sll $v0, $v0, 0x10
.L8006463C:
/* 3FA3C 8006463C 03E00008 */ jr $ra
/* 3FA40 80064640 00021403 */ sra $v0, $v0, 0x10
/* 3FA44 80064644 00000000 */ nop
/* 3FA48 80064648 00000000 */ nop
/* 3FA4C 8006464C 00000000 */ nop
`,
}
export const OverScrollTest: ComponentStory<typeof MonacoEditor> = () => {
const args = Mips.args
const [value, setValue] = useState(args.value)
return <div style={{ height: "150vh" }}>
Page should begin scrolling after the editor hits the bottom
<div style={{ display: "flex", width: "95vw", height: "400px" }}>
<MonacoEditor {...args} value={value} onChange={setValue} language="mips" />
</div>
</div>
}
export const Readonly: ComponentStory<typeof MonacoEditor> = () => {
const args = Mips.args
return <div style={{ display: "flex", width: "95vw", height: "95vh" }}>
<MonacoEditor {...args} value={args.value} language="mips" />
</div>
}
@@ -0,0 +1,163 @@
import { useEffect, useState, useRef } from "react"
import classNames from "classnames"
import * as monaco from "monaco-editor"
import { editor } from "monaco-editor"
import * as c from "./language/c"
import * as mips from "./language/mips"
import styles from "./MonacoEditor.module.scss"
import monacoTheme from "./monacoTheme"
import "monaco-editor/min/vs/editor/editor.main.css"
if (typeof window === "undefined") {
throw new Error("Editor component does not work with SSR, use next/dynamic with { ssr: false }")
}
monaco.languages.register({ id: "decompme_c" })
monaco.languages.setLanguageConfiguration("decompme_c", c.conf)
monaco.languages.setMonarchTokensProvider("decompme_c", c.language)
monaco.languages.register({ id: "decompme_mips" })
monaco.languages.setLanguageConfiguration("decompme_mips", mips.conf)
monaco.languages.setMonarchTokensProvider("decompme_mips", mips.language)
function convertLanguage(language: string) {
if (language === "c")
return "decompme_c"
else if (language === "mips")
return "decompme_mips"
else
return "plaintext"
}
export type Props = {
className?: string,
// This is a controlled component
value: string,
onChange?: (value: string) => void,
// Options
language: "c" | "mips",
lineNumbers?: boolean,
showMargin?: boolean,
padding?: number, // css
}
export default function Editor({ value, onChange, className, showMargin, padding, language, lineNumbers }: Props) {
const isReadOnly = typeof onChange === "undefined"
const containerRef = useRef<HTMLDivElement>(null)
const [editor, setEditor] = useState<editor.IStandaloneCodeEditor | null>(null)
// Effect to set up the editor. This is run once when the component is mounted.
useEffect(() => {
monaco.editor.defineTheme("custom", monacoTheme())
const editor = monaco.editor.create(containerRef.current, {
language: convertLanguage(language),
value,
theme: "custom",
autoDetectHighContrast: false,
minimap: {
enabled: false,
},
lineNumbers: lineNumbers ? "on" : "off",
renderLineHighlightOnlyWhenFocus: true,
scrollBeyondLastLine: false,
scrollbar: {
alwaysConsumeMouseWheel: false,
},
contextmenu: false,
//fontLigatures: true,
//fontFamily: "Jetbrains Mono",
readOnly: isReadOnly,
domReadOnly: isReadOnly,
occurrencesHighlight: !isReadOnly,
renderLineHighlight: isReadOnly ? "none" : "all",
padding: {
top: padding ?? (showMargin ? 20 : 0), // to match gutter
bottom: padding ?? 0,
},
glyphMargin: !!showMargin,
folding: !!showMargin,
lineDecorationsWidth: padding ?? (showMargin ? 10 : 0),
lineNumbersMinChars: showMargin ? 5 : 0,
automaticLayout: true,
})
setEditor(editor)
const model = editor.getModel()
if (model) {
model.onDidChangeContent(() => {
if (onChange)
onChange(model.getValue())
})
} else {
console.error("monaco editor has no model")
}
return () => editor.dispose()
}, []) // eslint-disable-line react-hooks/exhaustive-deps
// Update value.
useEffect(() => {
const model = editor?.getModel()
// Only update the model value if it is different; otherwise, the
// model state will be reset every time the user types!
if (model && model.getValue() !== value) {
console.log("editor value reset")
model.setValue(value)
}
}, [editor, value])
// Update language.
useEffect(() => {
const model = editor?.getModel()
if (model) {
monaco.editor.setModelLanguage(model, convertLanguage(language))
}
}, [editor, language])
useEffect(() => {
editor?.updateOptions({ lineNumbers: lineNumbers ? "on" : "off" })
}, [editor, lineNumbers])
return <div
ref={containerRef}
className={classNames(
styles.container,
className,
{
[styles.readonly]: isReadOnly,
},
)}
onKeyDownCapture={e => {
if (isReadOnly) {
// disable changing lines with arrow keys
if (e.key === "ArrowDown" || e.key === "ArrowUp" || e.key === "ArrowLeft" || e.key === "ArrowRight") {
e.stopPropagation()
}
// disable command palette
if (e.key === "F1") {
e.preventDefault()
e.stopPropagation()
}
} else {
// Command Palette
if ((e.ctrlKey || e.metaKey) && e.key === "p") {
e.preventDefault()
e.stopPropagation()
if (e.shiftKey)
editor?.trigger("", "editor.action.quickCommand", "")
//console.log(editor.getSupportedActions())
}
}
}}
/>
}
+3
View File
@@ -0,0 +1,3 @@
import Editor from "./Editor"
export default Editor
@@ -1,4 +1,6 @@
export const conf = {
import type { languages } from "monaco-editor"
export const conf: languages.LanguageConfiguration = {
comments: {
lineComment: "//",
blockComment: ["/*", "*/"]
@@ -27,10 +29,10 @@ export const conf = {
start: new RegExp("^\\s*#pragma\\s+region\\b"),
end: new RegExp("^\\s*#pragma\\s+endregion\\b")
}
}
},
}
export const language = {
export const language: languages.IMonarchLanguage = {
defaultToken: "",
tokenPostfix: ".c",
@@ -431,7 +433,7 @@ export const language = {
"keyword.directive.include.begin",
"string.include.identifier",
{ token: "keyword.directive.include.end", next: "@pop" }
]
] as languages.IMonarchLanguageAction
],
[
/(\s*)(")([^"]*)(")/,
@@ -440,7 +442,7 @@ export const language = {
"keyword.directive.include.begin",
"string.include.identifier",
{ token: "keyword.directive.include.end", next: "@pop" }
]
] as languages.IMonarchLanguageAction
]
]
}
@@ -0,0 +1,167 @@
import type { languages } from "monaco-editor"
export const conf: languages.LanguageConfiguration = {
comments: {
lineComment: "#",
blockComment: ["/*", "*/"]
},
brackets: [
["(", ")"],
],
autoClosingPairs: [
{ open: "(", close: ")" },
{ open: "\"", close: "\"", notIn: ["string"] }
],
surroundingPairs: [
{ open: "(", close: ")" },
{ open: "\"", close: "\"" },
],
}
export const language: languages.IMonarchLanguage = {
defaultToken: "",
tokenPostfix: ".mips",
brackets: [
{ token: "delimiter.parenthesis", open: "(", close: ")" },
],
keywords: [
"glabel",
],
instructions: [
"lb", "lbu", "ld", "ldl", "ldr", "lh", "lhu", "ll", "lld", "lw", "lwl", "lwr", "lwu", "sb", "sc", "scd", "sd", "sdl", "sdr", "sh", "sw", "swl", "swr", "sync", "add", "addi", "addiu", "addu", "and", "andi", "dadd", "daddi", "daddiu", "daddu", "ddiv", "ddivu", "div", "divu", "dmult", "dmultu", "dsll", "dsll32", "dsllv", "dsra", "dsra32", "dsrav", "dsrl", "dsrl32", "dsrlv", "dsub", "dsubu", "lui", "mfhi", "mflo", "mthi", "mtlo", "mult", "multu", "nor", "or", "ori", "sll", "sllv", "slt", "slti", "sltiu", "sltu", "sra", "srav", "srl", "srlv", "sub", "subu", "xor", "xori", "beq", "beql", "bgez", "bgezal", "bgezall", "bgezl", "bgtz", "bgtzl", "blez", "blezl", "bltz", "bltzal", "bltzall", "bltzl", "bne", "bnel", "j", "jal", "jalr", "jr", "break", "syscall", "teq", "teqi", "tge", "tgei", "tgeiu", "tgeu", "tlt", "tlti", "tltiu", "tltu", "tne", "tnei", "cache", "dmfc0", "dmtc0", "eret", "mfc0", "mtc0", "tlbp", "tlbr", "tlbwi", "tlbwr", "bc1f", "bc1fl", "bc1t", "bc1tl", "cfc1", "ctc1", "dmfc1", "dmtc1", "ldc1", "lwc1", "mfc1", "mtc1", "sdc1", "swc1",
"beqz", "bnez", "negu",
],
registers: [
"$zero", "$t0", "$s0", "$t8", "$at", "$t1", "$s1", "$t9", "$v0", "$t2", "$s2", "$k0", "$v1", "$t3", "$s3", "$k1", "$a0", "$t4", "$s4", "$gp", "$a1", "$t5", "$s5", "$sp", "$a2", "$t6", "$s6", "$s8", "$a3", "$t7", "$s7", "$ra",
],
// we include these common regular expressions
escapes: /\\(?:[abfnrtv\\"']|x[0-9A-Fa-f]{1,4}|u[0-9A-Fa-f]{4}|U[0-9A-Fa-f]{8})/,
integersuffix: /(ll|LL|u|U|l|L)?(ll|LL|u|U|l|L)?/,
encoding: /u|u8|U|L/,
// The main tokenizer for our languages
tokenizer: {
root: [
// whitespace
{ include: "@whitespace" },
// identifiers and keywords
[
/[a-zA-Z_]\w*/,
{
cases: {
"jal": { token: "function" },
"@instructions": { token: "support.function.$0" },
"@keywords": { token: "keyword.$0" },
"@default": "identifier"
}
}
],
[
/\$\w+/,
{
cases: {
"@registers": { token: "entity.name.register.$0" },
"@default": "identifier"
}
}
],
[/%(hi|lo)/, "macro"],
[/\.\w+/, { token: "keyword.directive" }],
// delimiters and operators
[/[()]/, "@brackets"],
// numbers
[/\d*\d+[eE]([-+]?\d+)?/, "number.float"],
[/\d*\.\d+([eE][-+]?\d+)?/, "number.float"],
[/0[xX][0-9a-fA-F']*[0-9a-fA-F](@integersuffix)/, "number.hex"],
[/0[0-7']*[0-7](@integersuffix)/, "number.octal"],
[/0[bB][0-1']*[0-1](@integersuffix)/, "number.binary"],
[/\d[\d']*\d(@integersuffix)/, "number"],
[/\d(@integersuffix)/, "number"],
// delimiter: after number because of .\d floats
[/[;,.]/, "delimiter"],
// strings
[/"([^"\\]|\\.)*$/, "string.invalid"], // non-teminated string
[/"/, "string", "@string"],
// characters
[/'[^\\']'/, "string"],
[/(')(@escapes)(')/, ["string", "string.escape", "string"]],
[/'/, "string.invalid"]
],
whitespace: [
[/[ \t\r\n]+/, ""],
[/\/\*/, "comment", "@comment"],
[/#.*\\$/, "comment", "@linecomment"],
[/#.*$/, "comment"]
],
comment: [
[/[^/*]+/, "comment"],
[/\*\//, "comment", "@pop"],
[/[/*]/, "comment"]
],
//For use with continuous line comments
linecomment: [
[/.*[^#]$/, "comment", "@pop"],
[/[^]+/, "comment"]
],
string: [
[/[^\\"]+/, "string"],
[/@escapes/, "string.escape"],
[/\\./, "string.escape.invalid"],
[/"/, "string", "@pop"]
],
raw: [
[
/(.*)(\))(?:([^ ()\\\t"]*))(")/,
{
cases: {
"$3==$S2": [
"string.raw",
"string.raw.end",
"string.raw.end",
{ token: "string.raw.end", next: "@pop" }
],
"@default": ["string.raw", "string.raw", "string.raw", "string.raw"]
}
}
],
[/.*/, "string.raw"]
],
include: [
[
/(\s*)(<)([^<>]*)(>)/,
[
"",
"keyword.directive.include.begin",
"string.include.identifier",
{ token: "keyword.directive.include.end", next: "@pop" }
] as languages.IMonarchLanguageAction
],
[
/(\s*)(")([^"]*)(")/,
[
"",
"keyword.directive.include.begin",
"string.include.identifier",
{ token: "keyword.directive.include.end", next: "@pop" }
] as languages.IMonarchLanguageAction
]
]
}
}
@@ -0,0 +1,38 @@
import type { editor } from "monaco-editor"
export default function getTheme(): editor.IStandaloneThemeData {
const style = typeof window !== "undefined" ? window.getComputedStyle(document.body) : null
return {
"base": "vs-dark",
"inherit": false,
"rules": [
{ "token": "", "foreground": "8599b3" },
{ "token": "identifier", "foreground": "8599B3" },
{ "token": "delimiter", "foreground": "555f6e" },
{ "token": "operator", "foreground": "7F83FF" },
{ "token": "operator.comparison", "foreground": "FF4ABA" },
{ "token": "comment", "foreground": "465173" },
{ "token": "string", "foreground": "0AFAFA" },
{ "token": "number", "foreground": "0AFAFA" },
{ "token": "function", "foreground": "FF4A98" },
{ "token": "constant.language", "foreground": "0AFAFA" },
{ "token": "constant.character, constant.other", "foreground": "0AFAFA" },
{ "token": "keyword", "foreground": "7F83FF" },
{ "token": "entity.name", "foreground": "FF4A98" },
{ "token": "entity.other.attribute-name", "foreground": "FF4A98" },
{ "token": "support.function", "foreground": "45B8FF" },
{ "token": "storage", "foreground": "45B8FF" },
{ "token": "macro", "foreground": "3bff6c" }
],
"colors": {
"editor.foreground": "#8599b3",
"editor.background": style?.getPropertyValue?.("--g200") || "#111415",
"editor.selectionBackground": "#ffffff22",
"editor.lineHighlightBackground": "#ccccff07",
"editorCursor.foreground": "#c9cbfc",
"editorWhitespace.foreground": "#c9cbfc11",
"editorLineNumber.foreground":"#ccccff31"
}
}
}
@@ -0,0 +1,44 @@
.footer {
width: 100%;
color: var(--g1400);
cursor: default;
svg {
display: block;
}
}
.main {
padding: 1em;
border-top: 1px solid var(--g300);
border-bottom: 1px solid var(--g300);
background: var(--g200);
}
.links {
display: flex;
justify-content: space-around;
}
.link {
display: inline-flex;
gap: .5em;
align-items: center;
padding: 1em;
font-weight: 600;
&:hover {
text-decoration: underline;
}
}
.commitHash {
text-align: center;
font-size: .7em;
background: var(--g200);
color: var(--g600);
}
+45
View File
@@ -0,0 +1,45 @@
import Link from "next/link"
import { MarkGithubIcon } from "@primer/octicons-react"
import Discord from "./discord.svg"
import styles from "./Footer.module.scss"
const commitHash = process.env.NEXT_PUBLIC_COMMIT_HASH ?? process.env.STORYBOOK_COMMIT_HASH
export default function Footer() {
return <footer className={styles.footer}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 250">
<path
fill="var(--g200)"
fillOpacity="1"
d="M0,128L60,138.7C120,149,240,171,360,160C480,149,600,107,720,85.3C840,64,960,64,1080,90.7C1200,117,1320,171,1380,197.3L1440,224L1440,320L1380,320C1320,320,1200,320,1080,320C960,320,840,320,720,320C600,320,480,320,360,320C240,320,120,320,60,320L0,320Z"
/>
</svg>
<div className={styles.main}>
<div className={styles.links}>
<Link href="https://github.com/ethteck/decomp.me">
<a className={styles.link}>
<MarkGithubIcon size={24} />
Contribute to decomp.me
</a>
</Link>
<Link href="https://discord.gg/sutqNShRRs">
<a className={styles.link}>
<Discord width={24} />
Chat on Discord
</a>
</Link>
</div>
<div className={styles.commitHash}>
<Link href={`https://github.com/ethteck/decomp.me/commit/${commitHash}`}>
<a>
{commitHash.slice(0, 7)}
</a>
</Link>
</div>
</div>
</footer>
}
@@ -1,19 +1,21 @@
import { h } from "preact"
import { MarkGithubIcon } from "@primer/octicons-react"
import { useSWRConfig } from "swr"
const { GITHUB_CLIENT_ID } = import.meta.env
import Button from "./Button"
const GITHUB_CLIENT_ID = process.env.NEXT_PUBLIC_GITHUB_CLIENT_ID ?? process.env.STORYBOOK_GITHUB_CLIENT_ID
// https://docs.github.com/en/developers/apps/building-oauth-apps/scopes-for-oauth-apps
const SCOPES = ["public_repo"]
const SCOPES = []
const LOGIN_URL = `https://github.com/login/oauth/authorize?client_id=${GITHUB_CLIENT_ID}&scope=${SCOPES.join("%20")}`
export default function GitHubLoginButton() {
export default function GitHubLoginButton({ label }: { label?: string }) {
const { mutate } = useSWRConfig()
const showLoginWindow = (evt: MouseEvent) => {
const showLoginWindow = (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
const win = window.open(LOGIN_URL, "Sign in with GitHub", "resizable,scrollbars,status")
evt.preventDefault()
event.preventDefault()
win.addEventListener("close", () => {
mutate("/user")
@@ -21,15 +23,9 @@ export default function GitHubLoginButton() {
}
if (GITHUB_CLIENT_ID) {
return <a
class="button"
href={LOGIN_URL}
target="_blank"
rel="noreferrer"
onClick={showLoginWindow}
>
<MarkGithubIcon size={16} /> Sign in with GitHub
</a>
return <Button onClick={showLoginWindow}>
<MarkGithubIcon size={16} /> {label ?? "Sign in with GitHub"}
</Button>
} else {
// The backend is not configured to support GitHub login
return <button disabled>
+19
View File
@@ -0,0 +1,19 @@
import { Toaster } from "react-hot-toast"
export default function Layout({ children }: { children: React.ReactNode }) {
return <>
{children}
<Toaster
position="bottom-center"
reverseOrder={true}
toastOptions={{
style: {
borderRadius: "100px",
background: "#333",
color: "#fff",
},
}}
/>
</>
}
@@ -0,0 +1,14 @@
@use "./Nav.module.scss"; // .item
.user {
@extend .item;
gap: 6px;
}
.avatar {
border-radius: 24px;
}
.loginContainer, .user {
margin-right: .5em;
}
@@ -0,0 +1,50 @@
import { useEffect } from "react"
import Image from "next/image"
import Link from "next/link"
import useSWR from "swr"
import * as api from "../../lib/api"
import GitHubLoginButton from "../GitHubLoginButton"
import styles from "./LoginState.module.scss"
export type Props = {
onChange: (user: api.AnonymousUser | api.User) => void,
}
export default function LoginState({ onChange }: Props) {
const { data: user, error } = useSWR<api.AnonymousUser | api.User>("/user", api.get)
useEffect(() => {
if (user) {
onChange(user)
}
}, [user, onChange])
if (error) {
return <div>{error}</div>
} else if (!user) {
// Loading...
return <div />
} else if (user && !api.isAnonUser(user) && user.username) {
return <Link href={`/u/${user.username}`}>
<a title={`@${user.username}`} className={styles.user}>
{user.avatar_url && <Image
className={styles.avatar}
src={user.avatar_url}
alt="User avatar"
width={24}
height={24}
priority
/>}
<span>{user.name}</span>
</a>
</Link>
} else {
return <div className={styles.loginContainer}>
<GitHubLoginButton label="Sign in" />
</div>
}
}
@@ -0,0 +1,76 @@
.nav {
flex: 0;
width: 100%;
user-select: none;
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: .5em;
padding: 1vh 0.5vw;
background: var(--g400);
border-bottom: 1px solid var(--g600);
font-size: 90%;
font-weight: 600;
}
.item {
display: flex;
align-items: center;
height: 100%;
padding: 0 8px;
opacity: 1.0;
&:hover {
opacity: 0.8;
}
}
.logo {
@extend .item;
margin: 0 .5vw;
padding: 0;
svg {
pointer-events: none;
}
/* Frog blinks when you load the page or stop hovering */
&:not(:hover) {
@keyframes blink {
0% { transform: scaleY(1.0); opacity: 1 }
25% { transform: scaleY(0.1); opacity: 0 }
50% { transform: scaleY(1.0); opacity: 1 }
75% { transform: scaleY(0.1); opacity: 0 }
100% { transform: scaleY(1.0); opacity: 1 }
}
:global(.frog_svg__pupilR),
:global(.frog_svg__pupilL),
:global(.frog_svg__eyeR),
:global(.frog_svg__eyeL) {
transform-origin: 0 7px;
animation: blink .4s 2s ease;
@media (prefers-reduced-motion) {
animation: none;
}
}
}
}
.grow {
flex: 1;
}
.link:hover, .linkActive {
opacity: 0.8;
}
+29
View File
@@ -0,0 +1,29 @@
import Link from "next/link"
import Frog from "./frog.svg"
import LoginState, { Props as LoginStateProps } from "./LoginState"
import styles from "./Nav.module.scss"
const onUserChangeNop = (_user: any) => {}
export type Props = {
onUserChange?: LoginStateProps["onChange"],
}
export default function Nav({ onUserChange }: Props) {
return <nav className={styles.nav}>
<Link href="/">
<a className={styles.logo}>
<Frog width={32} height={32} />
</a>
</Link>
<Link href="/scratch">
<a className={styles.item}>New scratch</a>
</Link>
<div className={styles.grow} />
<LoginState onChange={onUserChange ?? onUserChangeNop} />
</nav>
}
+11
View File
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36">
<title>decomp.me</title>
<path fill="var(--frog-secondary)" d="M36 22c0 7.456-8.059 12-18 12S0 29.456 0 22 8.059 7 18 7s18 7.544 18 15z"/>
<path fill="var(--frog-primary)" d="M31.755 12.676C33.123 11.576 34 9.891 34 8c0-3.313-2.687-6-6-6-2.861 0-5.25 2.004-5.851 4.685-1.288-.483-2.683-.758-4.149-.758-1.465 0-2.861.275-4.149.758C13.25 4.004 10.861 2 8 2 4.687 2 2 4.687 2 8c0 1.891.877 3.576 2.245 4.676C1.6 15.356 0 18.685 0 22c0 7.456 8.059 1 18 1s18 6.456 18-1c0-3.315-1.6-6.644-4.245-9.324z"/>
<circle fill="#FFF" cx="7.5" cy="7.5" r="3.5" class="eyeL" />
<circle fill="var(--frog-pupil)" cx="7.5" cy="7.5" r="1.5" class="pupilL" />
<circle fill="#FFF" cx="28.5" cy="7.5" r="3.5" class="eyeR" />
<circle fill="var(--frog-pupil)" cx="28.5" cy="7.5" r="1.5" class="pupilR" />
<circle fill="var(--frog-nose)" cx="14" cy="20" r="1"/>
<circle fill="var(--frog-nose)" cx="22" cy="20" r="1"/>
</svg>

After

Width:  |  Height:  |  Size: 995 B

+3
View File
@@ -0,0 +1,3 @@
import Nav from "./Nav"
export default Nav
@@ -2,19 +2,17 @@
display: inline-flex;
position: relative;
background: #00000044;
color: #ffffffcc;
background: var(--g200);
color: var(--g1600);
border: 1px solid var(--g400);
border: 0;
border-radius: 0.5em;
border-radius: 4px;
padding: .6em 1em;
padding: 8px 10px;
font-size: .8rem;
user-select: none;
box-shadow: 0 2px 16px #00000022;
}
.group select {
@@ -27,8 +25,8 @@
}
.group option, .group optgroup {
color: white;
background: #14161a;
color: var(--g1600);
background: var(--g200);
}
.icon {
+23
View File
@@ -0,0 +1,23 @@
import { ReactNode, ChangeEventHandler } from "react"
import { ChevronDownIcon } from "@primer/octicons-react"
import styles from "./Select.module.scss"
export type Props = {
className?: string,
onChange: ChangeEventHandler<HTMLSelectElement>,
children: ReactNode,
}
export default function Select({ onChange, children, className }: Props) {
return <div className={`${styles.group} ${className}`}>
<select onChange={onChange}>
{children}
</select>
<div className={styles.icon}>
<ChevronDownIcon size={16} />
</div>
</div>
}
@@ -0,0 +1,30 @@
import { useState } from "react"
import { ComponentStory, ComponentMeta } from "@storybook/react"
import Select from "./Select2"
export default {
title: "Select",
component: Select,
} as ComponentMeta<typeof Select>
const Template: ComponentStory<typeof Select> = args => {
const [value, setValue] = useState<string>(args.value)
return <div>
<Select {...args} value={value} onChange={setValue} />
<br />
value: {value}
</div>
}
export const Default: ComponentStory<typeof Select> = Template.bind({})
Default.args = {
value: "initial",
options: {
"above": "Above",
"initial": "Initial value",
"below": "Below",
},
}
+32
View File
@@ -0,0 +1,32 @@
import { ChevronDownIcon } from "@primer/octicons-react"
import styles from "./Select.module.scss"
export type Props = {
options: { [key: string]: string },
value: string,
className?: string,
onChange: (value: string) => void,
}
export default function Select({ options, value, onChange, className }: Props) {
if (!value)
onChange(Object.keys(options)[0])
return <div className={`${styles.group} ${className}`}>
<select
value={value}
onChange={event => {
onChange(event.target.value)
}}
>
{Object.entries(options).map(([key, name]) =>
<option key={key} value={key}>{name}</option>
)}
</select>
<div className={styles.icon}>
<ChevronDownIcon size={16} />
</div>
</div>
}
@@ -1,5 +1,8 @@
.popover {
border: 1px solid var(--g500);
border-radius: 1em;
box-shadow: 0 2px 8px 0 #00000088;
max-width: 50em;
z-index: 999;
}
@@ -1,11 +1,14 @@
import { h, Fragment } from "preact"
import { useState } from "preact/hooks"
import { useLayer, Arrow } from "react-laag"
import { motion, AnimatePresence } from "framer-motion"
import { CpuIcon } from "@primer/octicons-react"
import { useState } from "react"
import { CpuIcon } from "@primer/octicons-react"
import { motion, AnimatePresence } from "framer-motion"
import { useLayer, Arrow } from "react-laag"
import { useThemeVariable } from "../../lib/hooks"
import Button from "../Button"
import CompilerOpts, { Props as CompilerOptsProps } from "./CompilerOpts"
import styles from "./CompilerButton.module.css"
import CompilerOpts, { Props as CompilerOptsProps } from "./CompilerOpts"
export type Props = {
arch: CompilerOptsProps["arch"],
@@ -16,6 +19,8 @@ export type Props = {
export default function CompilerButton({ arch, value, onChange, disabled }: Props) {
const [isOpen, setOpen] = useState(false)
const arrowColor = useThemeVariable("--g300")
const arrowBorderColor = useThemeVariable("--g500")
const close = () => setOpen(false)
@@ -29,18 +34,17 @@ export default function CompilerButton({ arch, value, onChange, disabled }: Prop
})
return <>
<button
<Button
{...triggerProps}
onClick={() => {
if (!disabled)
setOpen(!isOpen)
}}
style={isOpen && { color: "#fff" }}
disabled={disabled}
>
<CpuIcon size={16} />
Compiler...
</button>
</Button>
{renderLayer(
<AnimatePresence>
@@ -53,7 +57,13 @@ export default function CompilerButton({ arch, value, onChange, disabled }: Prop
{...layerProps}
>
<CompilerOpts isPopup={true} arch={arch} value={value} onChange={onChange} />
<Arrow size={12} backgroundColor="#292e35" {...arrowProps} />
<Arrow
size={12}
backgroundColor={arrowColor}
borderWidth={1}
borderColor={arrowBorderColor}
{...arrowProps}
/>
</motion.div>}
</AnimatePresence>
)}
@@ -4,7 +4,8 @@
padding: 1em;
padding-left: 1.5em;
padding-right: 1.5em;
background: #292e35;
background: var(--g300);
border-bottom: 1px solid var(--g400);
display: flex;
flex-direction: row;
@@ -16,13 +17,13 @@
border-bottom-left-radius: 1em;
border-bottom-right-radius: 1em;
padding: 1.5em;
background: #22272d;
background: var(--g300);
}
.header:not([data-is-popup]), .container:not([data-is-popup]) {
border-radius: 0;
background: transparent;
border-bottom: 1px solid #2f3136;
border-bottom: 1px solid var(--g400);
}
.row {
@@ -30,24 +31,25 @@
}
.compilerSelect {
border-top-left-radius: .5rem;
border-bottom-left-radius: .5rem;
border-top-left-radius: 4px;
border-bottom-left-radius: 4px;
border-top-right-radius: 0;
border-bottom-right-radius: 0;;
border-bottom-right-radius: 0;
border-right: 0;
}
.textbox {
flex: 1;
background: #00000044;
box-shadow: 0 2px 16px #00000022;
border: 0;
color: #0AFAFA;
background: var(--g200);
border: 1px solid var(--g400);
border-left: 0;
color: var(--frog-secondary);
border-top-left-radius: 0;
border-bottom-right-radius: 0;
border-top-right-radius: .5rem;
border-bottom-right-radius: .5rem;
border-top-right-radius: 4px;
border-bottom-right-radius: 4px;
font-family: monospace;
font-size: .8rem;
@@ -67,11 +69,11 @@
display: inline-block;
cursor: default;
padding: .75em;
border-radius: .5em;
border-radius: 12px;
}
.flag:hover {
background: #00000022;
background: var(--g400);
}
.flag label {
@@ -0,0 +1,22 @@
import { ComponentStory, ComponentMeta } from "@storybook/react"
import CompilerOpts from "./CompilerOpts"
export default {
title: "compiler/CompilerOpts",
component: CompilerOpts,
} as ComponentMeta<typeof CompilerOpts>
const Template: ComponentStory<typeof CompilerOpts> = args => {
return <CompilerOpts {...args} />
}
export const AnyArch: ComponentStory<typeof CompilerOpts> = Template.bind({})
AnyArch.args = {
}
export const MipsCompilersOnly: ComponentStory<typeof CompilerOpts> = Template.bind({})
MipsCompilersOnly.args = {
arch: "mips",
}
@@ -1,12 +1,10 @@
import { h, createContext } from "preact"
import { useState, useContext, useEffect } from "preact/hooks"
import Skeleton from "react-loading-skeleton"
import { createContext, useState, useContext, useEffect } from "react"
import Select from "../Select"
import styles from "./CompilerOpts.module.css"
import { useCompilersForArch } from "./compilers"
import PresetSelect, { PRESETS } from "./PresetSelect"
import styles from "./CompilerOpts.module.css"
interface IOptsContext {
checkFlag(flag: string): boolean,
@@ -20,18 +18,18 @@ export function Checkbox({ flag, description }) {
const isChecked = checkFlag(flag)
return <div class={styles.flag} onClick={() => setFlag(flag, !isChecked)}>
return <div className={styles.flag} onClick={() => setFlag(flag, !isChecked)}>
<input type="checkbox" checked={isChecked} onChange={() => setFlag(flag, !isChecked)} />
<label>{flag}</label>
<span class={styles.flagDescription}>{description}</span>
<span className={styles.flagDescription}>{description}</span>
</div>
}
export function FlagSet({ name, children }) {
const { setFlag } = useContext(OptsContext)
return <div class={styles.flagSet}>
<div class={styles.flagSetName}>{name}</div>
return <div className={styles.flagSet}>
<div className={styles.flagSetName}>{name}</div>
<Select
onChange={event => {
for (const child of children) {
@@ -64,7 +62,7 @@ export type CompilerOptsT = {
export type Props = {
arch?: string,
value: CompilerOptsT,
value?: CompilerOptsT,
onChange: (value: CompilerOptsT) => void,
title?: string,
isPopup?: boolean,
@@ -99,17 +97,17 @@ export default function CompilerOpts({ arch, value, onChange, title, isPopup }:
setOpts(opts)
},
}}>
<div class={styles.header} data-is-popup={isPopup}>
<div className={styles.header} data-is-popup={isPopup}>
{title || "Compiler Options"}
<PresetSelect arch={arch} compiler={compiler} setCompiler={setCompiler} opts={opts} setOpts={setOpts} />
</div>
<div class={styles.container} data-is-popup={isPopup}>
<div className={styles.container} data-is-popup={isPopup}>
<OptsEditor arch={arch} compiler={compiler} setCompiler={setCompiler} opts={opts} setOpts={setOpts} />
</div>
</OptsContext.Provider>
}
function OptsEditor({ arch, compiler, setCompiler, opts, setOpts }: {
export function OptsEditor({ arch, compiler, setCompiler, opts, setOpts }: {
arch?: string,
compiler: string,
setCompiler: (compiler: string) => void,
@@ -130,9 +128,9 @@ function OptsEditor({ arch, compiler, setCompiler, opts, setOpts }: {
}
return <div>
<div class={styles.row}>
<div className={styles.row}>
<Select
class={styles.compilerSelect}
className={styles.compilerSelect}
onChange={e => setCompiler((e.target as HTMLSelectElement).value)}
>
{Object.values(compilers).map(c => <option
@@ -146,15 +144,15 @@ function OptsEditor({ arch, compiler, setCompiler, opts, setOpts }: {
<input
type="text"
class={styles.textbox}
className={styles.textbox}
value={opts}
placeholder="no arguments"
onChange={e => setOpts((e.target as HTMLInputElement).value)}
/>
</div>
<div class={styles.flags}>
{(compiler && compilerModule) ? <compilerModule.Flags /> : <Skeleton />}
<div className={styles.flags}>
{(compiler && compilerModule) ? <compilerModule.Flags /> : <div />}
</div>
</div>
}
@@ -1,6 +1,6 @@
import { h } from "preact"
import Select from "../Select"
import { useCompilersForArch } from "./compilers"
export const PRESETS = [
@@ -1,4 +1,3 @@
import { h, Fragment } from "preact"
import { Checkbox, FlagSet, FlagOption } from "../CompilerOpts"
export const name = "EE GCC 2.96"
@@ -1,4 +1,3 @@
import { h, Fragment } from "preact"
import { Checkbox, FlagSet, FlagOption } from "../CompilerOpts"
export const name = "GCC 2.8.1"
@@ -1,4 +1,3 @@
import { h, Fragment } from "preact"
import { Checkbox, FlagSet, FlagOption } from "../CompilerOpts"
export const name = "IDO 5.3"
@@ -1,4 +1,3 @@
import { h, Fragment } from "preact"
import { Checkbox, FlagSet, FlagOption } from "../CompilerOpts"
export const name = "IDO 7.1"
@@ -1,13 +1,11 @@
import { FunctionComponent } from "preact"
import { FunctionComponent } from "react"
import * as api from "../../api"
import * as api from "../../../lib/api"
import * as EeGcc296 from "./ee-gcc2.96"
import * as Gcc281 from "./gcc2.8.1"
import * as Ido53 from "./ido5.3"
import * as Ido71 from "./ido7.1"
import * as EeGcc296 from "./ee-gcc2.96"
export type CompilerModule = { id: string, name: string, Flags: FunctionComponent }
const COMPILERS: CompilerModule[] = [
Gcc281,
@@ -16,6 +14,8 @@ const COMPILERS: CompilerModule[] = [
EeGcc296,
]
export type CompilerModule = { id: string, name: string, Flags: FunctionComponent }
export default COMPILERS
export function useCompilersForArch(arch?: string) {
@@ -1,9 +1,22 @@
import { h, Fragment } from "preact"
import * as api from "../api"
import * as api from "../../lib/api"
import styles from "./Diff.module.css"
function FormatDiffText({ texts }: { texts: api.DiffText[] }) {
return <> {
texts.map(t => {
if (t.format == "rotation") {
return <span className={styles[`rotation${t.index % 9}`]}>{t.text}</span>
} else if (t.format) {
return <span className={styles[t.format]}>{t.text}</span>
} else {
return <span>{t.text}</span>
}
})
} </>
}
export type Props = {
compilation: api.Compilation,
}
@@ -11,14 +24,14 @@ export type Props = {
export default function Diff({ compilation }: Props) {
const diff: api.DiffOutput = compilation.diff_output
if (!diff || diff.error) {
return <div class={styles.container}>
{compilation.errors && <div class={styles.log}>{compilation.errors}</div>}
{(diff && diff.error) && <div class={styles.log}>{diff.error}</div>}
return <div className={styles.container}>
{compilation.errors && <div className={styles.log}>{compilation.errors}</div>}
{(diff && diff.error) && <div className={styles.log}>{diff.error}</div>}
</div>
} else {
const threeWay = !!diff.header.previous
return <div class={styles.container}>
<table class={styles.diff}>
return <div className={styles.container}>
<table className={styles.diff}>
<tr>
<th><FormatDiffText texts={diff.header.base} /></th>
<th>{/* Line */}</th>
@@ -28,7 +41,7 @@ export default function Diff({ compilation }: Props) {
{diff.rows.map((row, i) => (
<tr key={i}>
<td>{row.base && <FormatDiffText texts={row.base.text} />}</td>
<td><span class={styles.lineNumber}>{(row.current) && row.current.src_line}</span></td>
<td><span className={styles.lineNumber}>{(row.current) && row.current.src_line}</span></td>
<td>{row.current && <FormatDiffText texts={row.current.text} />}</td>
{threeWay && <td>
{ row.previous && <FormatDiffText texts={row.previous.text} />}
@@ -39,17 +52,3 @@ export default function Diff({ compilation }: Props) {
</div>
}
}
function FormatDiffText({ texts }: { texts: api.DiffText[] }) {
return <> {
texts.map(t => {
if (t.format == "rotation") {
return <span class={styles[`rotation${t.index % 9}`]}>{t.text}</span>
} else if (t.format) {
return <span class={styles[t.format]}>{t.text}</span>
} else {
return <span>{t.text}</span>
}
})
} </>
}
+3
View File
@@ -0,0 +1,3 @@
<svg viewBox="0 0 71 55" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M60.1045 4.8978C55.5792 2.8214 50.7265 1.2916 45.6527 0.41542C45.5603 0.39851 45.468 0.440769 45.4204 0.525289C44.7963 1.6353 44.105 3.0834 43.6209 4.2216C38.1637 3.4046 32.7345 3.4046 27.3892 4.2216C26.905 3.0581 26.1886 1.6353 25.5617 0.525289C25.5141 0.443589 25.4218 0.40133 25.3294 0.41542C20.2584 1.2888 15.4057 2.8186 10.8776 4.8978C10.8384 4.9147 10.8048 4.9429 10.7825 4.9795C1.57795 18.7309 -0.943561 32.1443 0.293408 45.3914C0.299005 45.4562 0.335386 45.5182 0.385761 45.5576C6.45866 50.0174 12.3413 52.7249 18.1147 54.5195C18.2071 54.5477 18.305 54.5139 18.3638 54.4378C19.7295 52.5728 20.9469 50.6063 21.9907 48.5383C22.0523 48.4172 21.9935 48.2735 21.8676 48.2256C19.9366 47.4931 18.0979 46.6 16.3292 45.5858C16.1893 45.5041 16.1781 45.304 16.3068 45.2082C16.679 44.9293 17.0513 44.6391 17.4067 44.3461C17.471 44.2926 17.5606 44.2813 17.6362 44.3151C29.2558 49.6202 41.8354 49.6202 53.3179 44.3151C53.3935 44.2785 53.4831 44.2898 53.5502 44.3433C53.9057 44.6363 54.2779 44.9293 54.6529 45.2082C54.7816 45.304 54.7732 45.5041 54.6333 45.5858C52.8646 46.6197 51.0259 47.4931 49.0921 48.2228C48.9662 48.2707 48.9102 48.4172 48.9718 48.5383C50.038 50.6034 51.2554 52.5699 52.5959 54.435C52.6519 54.5139 52.7526 54.5477 52.845 54.5195C58.6464 52.7249 64.529 50.0174 70.6019 45.5576C70.6551 45.5182 70.6887 45.459 70.6943 45.3942C72.1747 30.0791 68.2147 16.7757 60.1968 4.9823C60.1772 4.9429 60.1437 4.9147 60.1045 4.8978ZM23.7259 37.3253C20.2276 37.3253 17.3451 34.1136 17.3451 30.1693C17.3451 26.225 20.1717 23.0133 23.7259 23.0133C27.308 23.0133 30.1626 26.2532 30.1066 30.1693C30.1066 34.1136 27.28 37.3253 23.7259 37.3253ZM47.3178 37.3253C43.8196 37.3253 40.9371 34.1136 40.9371 30.1693C40.9371 26.225 43.7636 23.0133 47.3178 23.0133C50.9 23.0133 53.7545 26.2532 53.6986 30.1693C53.6986 34.1136 50.9 37.3253 47.3178 37.3253Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

+32
View File
@@ -0,0 +1,32 @@
<!-- By Sam Herbert (@sherb), for everyone. More @ http://goo.gl/7AJzbL -->
<svg viewBox="0 0 38 38" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient x1="8.042%" y1="0%" x2="65.682%" y2="23.865%" id="a">
<stop stop-color="currentColor" stop-opacity="0" offset="0%"/>
<stop stop-color="currentColor" stop-opacity=".631" offset="63.146%"/>
<stop stop-color="currentColor" offset="100%"/>
</linearGradient>
</defs>
<g fill="none" fill-rule="evenodd">
<g transform="translate(1 1)">
<path d="M36 18c0-9.94-8.06-18-18-18" id="Oval-2" stroke="url(#a)" stroke-width="2">
<animateTransform
attributeName="transform"
type="rotate"
from="0 18 18"
to="360 18 18"
dur="0.9s"
repeatCount="indefinite" />
</path>
<circle fill="currentColor" cx="36" cy="18" r="1">
<animateTransform
attributeName="transform"
type="rotate"
from="0 18 18"
to="360 18 18"
dur="0.9s"
repeatCount="indefinite" />
</circle>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -1,6 +1,7 @@
.container {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
}
@@ -32,7 +33,7 @@
height: 3em;
padding: .5rem;
padding-left: 1rem;
background: #2e353f;
background: var(--g400);
display: flex;
flex-direction: row;
@@ -51,11 +52,7 @@
scrollbar-color: #ffffff33 transparent;
scrollbar-width: thin;
background: #14161a;
}
.diffSection .sectionHeader {
background: #1b1e22;
background: var(--g300);
}
.diffExplanation {
@@ -102,16 +99,26 @@
padding-left: 1rem;
gap: 1em;
color: #ffffff88;
color: var(--g1200);
cursor: default;
}
.metadata a {
color: #ffffffcc;
}
.metadata > div {
display: flex;
gap: 1ch;
align-items: center;
}
.chooseACompiler {
display: flex;
flex-direction: column;
}
.chooseACompilerActions {
padding: 1em;
align-self: center;
}
.scratchLink {
color: var(--g1400);
}
@@ -1,37 +1,81 @@
import { h, Fragment } from "preact"
import { useEffect } from "preact/hooks"
import * as resizer from "react-simple-resizer"
import { useState, useEffect } from "react"
import Link from "next/link"
import { RepoForkedIcon, SyncIcon, UploadIcon, ArrowRightIcon } from "@primer/octicons-react"
import { Link } from "react-router-dom"
import * as resizer from "react-simple-resizer"
import useDeepCompareEffect from "use-deep-compare-effect"
import * as api from "../api"
import * as api from "../../lib/api"
import AsyncButton from "../AsyncButton"
import Button from "../Button"
import CompilerButton from "../compiler/CompilerButton"
import CompilerOpts, { CompilerOptsT } from "../compiler/CompilerOpts"
import Editor from "./Editor"
import { useLocalStorage } from "../hooks"
import UserLink from "../user/UserLink"
import Diff from "../diff/Diff"
import AsyncButton from "../AsyncButton"
import Editor from "../Editor"
import UserLink from "../user/UserLink"
import styles from "./Scratch.module.css"
function nameScratch({ slug, owner }: api.Scratch): string {
function ChooseACompiler({ arch, onCommit }: {
arch: string,
onCommit: (opts: CompilerOptsT) => void,
}) {
const [compiler, setCompiler] = useState<CompilerOptsT>()
return <div className={styles.chooseACompiler}>
<CompilerOpts
title="Choose a compiler"
arch={arch}
value={compiler}
onChange={c => setCompiler(c)}
/>
<div className={styles.chooseACompilerActions}>
<Button primary onClick={() => onCommit(compiler)}>
Use this compiler
<ArrowRightIcon size={16} />
</Button>
</div>
</div>
}
function ScratchLink({ slug }: { slug: string }) {
const { scratch } = api.useScratch(slug)
if (!scratch) {
return <span />
}
return <Link href={`/scratch/${scratch.slug}`}>
<a className={styles.scratchLink}>
{nameScratch(scratch)}
</a>
</Link>
}
function DiffExplanation() {
return <span className={`${styles.diffExplanation} ${styles.visible}`}>
(left is target, right is your code)
</span>
}
export function nameScratch({ slug, owner }: api.Scratch): string {
if (owner?.is_you) {
return "your scratch"
} else if (!api.isAnonUser(owner) && owner?.name) {
return `${owner?.name}'s scratch`
return "Your Scratch"
} else if (owner && !api.isAnonUser(owner) && owner?.name) {
return `${owner?.name}'s Scratch`
} else {
return `scratch ${slug}`
return "Untitled Scratch"
}
}
export type Props = {
slug: string,
scratch: api.Scratch,
}
export default function Scratch({ slug }: Props) {
const { scratch, savedScratch, version, isSaved, setScratch, saveScratch, error } = api.useScratch(slug)
export default function Scratch({ scratch: initialScratch }: Props) {
const { scratch, savedScratch, isSaved, setScratch, saveScratch, error } = api.useScratch(initialScratch)
const { compilation, isCompiling, compile } = api.useCompilation(scratch, savedScratch, true)
const forkScratch = api.useForkScratchAndGo(scratch)
@@ -47,7 +91,7 @@ export default function Scratch({ slug }: Props) {
if ((event.ctrlKey || event.metaKey) && event.key == "s") {
event.preventDefault()
if (!isSaved && scratch.owner.is_you) {
if (!isSaved && scratch.owner?.is_you) {
saveScratch()
}
}
@@ -71,17 +115,17 @@ export default function Scratch({ slug }: Props) {
if (error?.status === 404) {
// TODO
return <div class={styles.container}>
return <div className={styles.container}>
Scratch not found
</div>
} else if (!scratch) {
// TODO
return <div class={styles.container}>
return <div className={styles.container}>
Loading scratch...
</div>
}
return <div class={styles.container}>
return <div className={styles.container}>
<resizer.Container className={styles.resizer}>
<resizer.Section minSize={500}>
<resizer.Container
@@ -89,30 +133,30 @@ export default function Scratch({ slug }: Props) {
style={{ height: "100%" }}
>
<resizer.Section minSize={200} className={styles.sourceCode}>
<div class={styles.sectionHeader}>
<div className={styles.sectionHeader}>
Source
<span class={styles.grow} />
<span className={styles.grow} />
{scratch.compiler !== "" && <>
<AsyncButton onPress={compile} forceLoading={isCompiling}>
<AsyncButton onClick={compile} forceLoading={isCompiling}>
<SyncIcon size={16} /> Compile
</AsyncButton>
<CompilerButton arch={scratch.arch} value={scratch} onChange={setCompilerOpts} />
</>}
</div>
<div class={styles.metadata}>
<div>
<div className={styles.metadata}>
{scratch.owner && <div>
Owner
<UserLink user={scratch.owner} />
</div>
</div>}
{scratch.parent && <div>
Fork of <ScratchLink slug={scratch.parent} />
</div>}
<div>
{scratch.owner.is_you && <AsyncButton onPress={() => {
{scratch.owner?.is_you && <AsyncButton onClick={() => {
return Promise.all([
saveScratch(),
compile(),
@@ -120,20 +164,21 @@ export default function Scratch({ slug }: Props) {
}} disabled={isSaved}>
<UploadIcon size={16} /> Save
</AsyncButton>}
<AsyncButton onPress={forkScratch}>
<AsyncButton onClick={forkScratch}>
<RepoForkedIcon size={16} /> Fork
</AsyncButton>
</div>
</div>
<Editor
padding
language="c"
value={scratch.source_code}
valueVersion={`${slug}:${version}`}
onChange={value => {
setScratch({ source_code: value })
}}
lineNumbers
showMargin
useLoadingSpinner
/>
</resizer.Section>
@@ -141,20 +186,20 @@ export default function Scratch({ slug }: Props) {
size={1}
style={{ cursor: "row-resize" }}
>
<div class={styles.sectionHeader}>
<div className={styles.sectionHeader}>
Context
</div>
</resizer.Bar>
<resizer.Section defaultSize={0} className={styles.context}>
<Editor
padding
language="c"
value={scratch.context}
valueVersion={`${slug}:${version}`}
onChange={value => {
setScratch({ context: value })
}}
showMargin
useLoadingSpinner
/>
</resizer.Section>
</resizer.Container>
@@ -164,14 +209,14 @@ export default function Scratch({ slug }: Props) {
size={1}
style={{
cursor: "col-resize",
background: "#2e3032",
background: "var(--g600)",
}}
expandInteractiveArea={{ left: 4, right: 4 }}
/>
<resizer.Section className={styles.diffSection} minSize={400}>
{scratch.compiler === "" ? <ChooseACompiler arch={scratch.arch} onCommit={setCompilerOpts} /> : <>
<div class={styles.sectionHeader}>
<div className={styles.sectionHeader}>
Diff
{compilation && <DiffExplanation />}
</div>
@@ -181,41 +226,3 @@ export default function Scratch({ slug }: Props) {
</resizer.Container>
</div>
}
function ChooseACompiler({ arch, onCommit }) {
const [compiler, setCompiler] = useLocalStorage<CompilerOptsT>("ChooseACompiler.recent")
return <div>
<CompilerOpts
title="Choose a compiler"
arch={arch}
value={compiler}
onChange={c => setCompiler(c)}
/>
<div style={{ padding: "1em", float: "right" }}>
<button onClick={() => onCommit(compiler)}>
Use this compiler
<ArrowRightIcon size={16} />
</button>
</div>
</div>
}
function ScratchLink({ slug }: { slug: string }) {
const { scratch } = api.useScratch(slug)
if (!scratch) {
return <span />
}
return <Link to={`/scratch/${scratch.slug}`}>
{nameScratch(scratch)}
</Link>
}
function DiffExplanation() {
return <span class={`${styles.diffExplanation} ${styles.visible}`}>
(left is target, right is your code)
</span>
}
@@ -4,19 +4,16 @@
justify-content: center;
gap: .5em;
border-radius: 2em;
padding: .6em;
padding-right: .9em;
color: var(--g1400);
}
.user:any-link {
cursor: pointer;
}
.user:any-link:hover {
background: #ffffff11;
}
.avatar {
border-radius: 999px;
width: 1.5em;
+25
View File
@@ -0,0 +1,25 @@
import Image from "next/image"
import Link from "next/link"
import * as api from "../../lib/api"
import styles from "./UserLink.module.css"
export type Props = {
user: api.User | api.AnonymousUser,
}
export default function UserLink({ user }: Props) {
if (api.isAnonUser(user)) {
return <a className={styles.user}>
<span>{user.is_you ? "you" : "anon" }</span>
</a>
} else {
return <Link href={`/u/${user.username}`}>
<a title={`@${user.username}`} className={styles.user}>
{user.avatar_url && <Image className={styles.avatar} src={user.avatar_url} alt="User avatar" width={24} height={24} />}
<span>{user.name} {user.is_you && <i>(you)</i>}</span>
</a>
</Link>
}
}
-30
View File
@@ -1,30 +0,0 @@
import { useState, useRef, useLayoutEffect, StateUpdater } from "preact/hooks"
import useResizeObserver from "@react-hook/resize-observer"
export function useLocalStorage<S>(key: string, initialValue: S = undefined): [S, StateUpdater<S>] {
const [storedValue, setStoredValue] = useState(() => {
const item = localStorage.getItem(key)
return item ? JSON.parse(item) : initialValue
})
const setValue = (value: S) => {
const valueToStore = value instanceof Function ? value(storedValue) : value
setStoredValue(valueToStore)
localStorage.setItem(key, JSON.stringify(valueToStore))
}
return [storedValue, setValue]
}
export function useSize<T extends HTMLElement>() {
const ref = useRef<T>()
const [size, setSize] = useState({ width: 0, height: 0 })
useLayoutEffect(() => {
setSize(ref.current.getBoundingClientRect())
}, [ref])
useResizeObserver(ref, entry => setSize(entry.contentRect))
return { width: size.width, height: size.height, ref }
}
-12
View File
@@ -1,12 +0,0 @@
declare module "*.module.css" {
const styles: { [className: string]: string }
export default styles
}
interface ImportMeta {
env: {
DEBUG: boolean,
API_BASE: string,
GITHUB_CLIENT_ID?: string,
},
}
-15
View File
@@ -1,15 +0,0 @@
import { h, render } from "preact"
import "preact/devtools"
import App from "./App.js"
const root = document.getElementById("root")
if (root) {
render(<App />, root)
} else {
console.error("this shouldn't happen...")
}
if (undefined /* [snowpack] import.meta.hot */ ) {
undefined /* [snowpack] import.meta.hot */ .accept()
}
+41 -50
View File
@@ -1,11 +1,13 @@
import { useState, useCallback, useEffect } from "preact/hooks"
import useSWR, { Revalidator, RevalidatorOptions } from "swr"
import { useState, useCallback, useEffect } from "react"
import { useRouter } from "next/router"
import { dequal } from "dequal/lite"
import { useHistory } from "react-router"
import useSWR, { Revalidator, RevalidatorOptions } from "swr"
import { useDebouncedCallback } from "use-debounce"
import useDeepCompareEffect from "use-deep-compare-effect"
const { API_BASE } = import.meta.env
const API_BASE = process.env.NEXT_PUBLIC_API_BASE ?? process.env.STORYBOOK_API_BASE
type Json = Record<string, unknown>
@@ -14,22 +16,23 @@ const commonOpts: RequestInit = {
cache: "reload",
}
// Read the Django CSRF token, from https://docs.djangoproject.com/en/3.2/ref/csrf/#ajax
export const csrftoken = (function (name) {
let cookieValue = null
if (document.cookie && document.cookie !== "") {
const cookies = document.cookie.split(";")
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim()
// Does this cookie string begin with the name we want?
if (cookie.substring(0, name.length + 1) === (`${name }=`)) {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1))
break
}
}
function isAbsoluteUrl(maybeUrl: string): boolean {
return maybeUrl.startsWith("https://") || maybeUrl.startsWith("http://")
}
function onErrorRetry<C>(error: ResponseError, key: string, config: C, revalidate: Revalidator, { retryCount }: RevalidatorOptions) {
if (error.status === 404) return
if (retryCount >= 10) return
// Retry after 5 seconds
setTimeout(() => revalidate({ retryCount }), 5000)
}
function undefinedIfUnchanged<O, K extends keyof O>(saved: O, local: O, key: K): O[K] | undefined {
if (saved[key] !== local[key]) {
return local[key]
}
return cookieValue
})("csrftoken")
}
export class ResponseError extends Error {
status: number
@@ -43,6 +46,8 @@ export class ResponseError extends Error {
if (responseJSON.error) {
this.message = responseJSON.error
} else if (responseJSON.detail) {
this.message = responseJSON.detail
} else if (responseJSON.errors) {
this.message = responseJSON.errors.join(",")
}
@@ -68,7 +73,7 @@ export async function get(url: string, useCacheIfFresh = false) {
return await response.json()
}
const getCached = (url: string) => get(url, true)
export const getCached = (url: string) => get(url, true)
export async function post(url: string, json: Json) {
if (url.startsWith("/")) {
@@ -85,7 +90,6 @@ export async function post(url: string, json: Json) {
body,
headers: {
"Content-Type": "application/json",
"X-CSRFToken": csrftoken,
},
})
@@ -111,7 +115,6 @@ export async function patch(url: string, json: Json) {
body,
headers: {
"Content-Type": "application/json",
"X-CSRFToken": csrftoken,
},
})
@@ -150,7 +153,7 @@ export type Scratch = {
cc_opts: string,
source_code: string,
context: string,
owner: AnonymousUser | User,
owner: AnonymousUser | User | null,
parent: string | null, // URL
diff_label: string | null,
}
@@ -204,25 +207,7 @@ export function isAnonUser(user: User | AnonymousUser): user is AnonymousUser {
return user.is_anonymous
}
function isAbsoluteUrl(maybeUrl: string): boolean {
return maybeUrl.startsWith("https://") || maybeUrl.startsWith("http://")
}
function onErrorRetry<C>(error: ResponseError, key: string, config: C, revalidate: Revalidator, { retryCount }: RevalidatorOptions) {
if (error.status === 404) return
if (retryCount >= 10) return
// Retry after 5 seconds
setTimeout(() => revalidate({ retryCount }), 5000)
}
function undefinedIfUnchanged<O, K extends keyof O>(saved: O, local: O, key: K): O[K] | undefined {
if (saved[key] !== local[key]) {
return local[key]
}
}
export function useScratch(slugOrUrl: string): {
export function useScratch(slugOrUrlOrInitialValue: string | Scratch): {
scratch: Readonly<Scratch> | null,
savedScratch: Readonly<Scratch> | null,
setScratch: (scratch: Partial<Scratch>) => void, // Update the scratch, but only locally
@@ -232,14 +217,20 @@ export function useScratch(slugOrUrl: string): {
isSaved: boolean,
error: ResponseError | null,
} {
if (!isAbsoluteUrl(slugOrUrl)) {
slugOrUrl = `/scratch/${slugOrUrl}`
let initialValue = null
if (typeof slugOrUrlOrInitialValue === "object") {
initialValue = slugOrUrlOrInitialValue
slugOrUrlOrInitialValue = slugOrUrlOrInitialValue.slug
}
if (!isAbsoluteUrl(slugOrUrlOrInitialValue)) {
slugOrUrlOrInitialValue = `/scratch/${slugOrUrlOrInitialValue}`
}
const [isSaved, setIsSaved] = useState(true)
const [version, setVersion] = useState(0)
const [localScratch, setLocalScratch] = useState<Scratch | null>(null)
const { data, error, mutate } = useSWR<Scratch, ResponseError>(slugOrUrl, get, {
const [localScratch, setLocalScratch] = useState<Scratch | null>(initialValue)
const { data, error, mutate } = useSWR<Scratch, ResponseError>(slugOrUrlOrInitialValue, get, {
refreshInterval: isSaved ? 5000 : 0,
onSuccess: scratch => {
if (!scratch.source_code) {
@@ -259,10 +250,10 @@ export function useScratch(slugOrUrl: string): {
// If the slug changes, forget the local scratch
useEffect(() => {
setLocalScratch(null)
setLocalScratch(initialValue)
mutate()
setIsSaved(true)
}, [slugOrUrl, mutate])
}, [initialValue, mutate])
const setScratch = useCallback((partial: Partial<Scratch>) => {
const scratch = Object.assign({}, localScratch, partial)
@@ -311,11 +302,11 @@ export async function forkScratch(parent: Scratch): Promise<Scratch> {
}
export function useForkScratchAndGo(parent: Scratch): () => Promise<void> {
const history = useHistory()
const router = useRouter()
return async () => {
const fork = await forkScratch(parent)
history.push(`/scratch/${fork.slug}`)
router.push(`/scratch/${fork.slug}`)
}
}
+40
View File
@@ -0,0 +1,40 @@
import { useState, useRef, useLayoutEffect, useEffect } from "react"
import useResizeObserver from "@react-hook/resize-observer"
export function useSize<T extends HTMLElement>() {
const ref = useRef<T>()
const [size, setSize] = useState({ width: 0, height: 0 })
useLayoutEffect(() => {
if (ref.current)
setSize(ref.current.getBoundingClientRect())
}, [ref])
useResizeObserver(ref, entry => setSize(entry.contentRect))
return { width: size.width, height: size.height, ref }
}
export function useBeforeUnload(fn: (event: BeforeUnloadEvent) => string) {
const cb = useRef(fn)
useEffect(() => {
const onUnload = cb.current
window.addEventListener("beforeunload", onUnload, { capture: true })
return () => {
window.removeEventListener("beforeunload", onUnload, { capture: true })
}
}, [cb])
}
export function useThemeVariable(variable: string): string {
const [value, setValue] = useState<string>()
useEffect(() => {
const style = window.getComputedStyle(document.body)
setValue(style.getPropertyValue(variable))
}, [variable])
return value
}
@@ -1,8 +1,47 @@
@use "sass:color";
@use "sass:math";
@mixin theme($accent: color, $bg: color) {
$accent-light: color.scale($accent, $lightness: 50%, $saturation: 30%);
$accent-dark: color.scale($accent, $lightness: -40%, $saturation: -20%);
--frog-pupil: #292f33;
--frog-primary: #{$accent};
--frog-secondary: #{$accent-light};
--frog-nose: #{$accent-dark};
--accent: #{$accent};
--complement: #{color.complement($accent)};
@for $i from 1 through 40 {
$multiplier: 2.5%;
--g#{$i * 50}: #{hsl(color.hue($bg), color.saturation($bg), ($i - 1) * $multiplier)};
}
$peak: 0;
@if color.blackness($bg) > 50% {
$peak: 255;
}
@for $i from 1 through 10 {
--a#{$i * 100}: #{rgba($peak, $peak, $peak, $i * 0.1)};
}
}
.themeGreen {
@include theme(#77b255, #37393e);
}
.themePlum {
@include theme(#951fd9, #292f33);
}
html {
font-size: 16px;
font-family: -apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif,Apple Color Emoji,Segoe UI Emoji;
line-height: 1.5;
text-rendering: optimizeQuality;
overflow: hidden;
--ratio: 1.25;
--s-5: calc(var(--s-4) / var(--ratio));
@@ -34,25 +73,25 @@ html {
}
body {
background: #0c0e12;
color: rgb(201, 199, 209);
background: var(--g300);
color: var(--a800);
line-height: var(--ratio);
overflow: hidden;
overflow-x: hidden;
user-select: none;
position: relative;
}
#root {
html, body {
height: 100%;
}
#__next {
display: flex;
flex-direction: column;
align-items: center;
height: 100vh;
}
#root > * {
flex: 1;
display: grid;
grid-template-rows: auto 1fr;
grid-template-areas:
"nav"
"main";
overflow-x: hidden;
}
#layers {
@@ -60,57 +99,14 @@ body {
}
main {
grid-area: main;
overflow: hidden;
}
button, .button {
background: #00000044;
color: #ffffffcc;
border: 1px solid #ffffff22;
border-radius: 0.25rem;
padding: .6em 1em;
font-size: .8rem;
user-select: none;
appearance: none;
text-decoration: none;
display: inline-flex;
gap: .5em;
align-items: center;
box-shadow: 0 2px 16px #00000022;
}
button, .button {
cursor: pointer;
}
button[disabled] {
color: #ffffff33;
cursor: default;
}
button:active, .button:active {
box-shadow: none;
}
label {
font-size: var(--s-1);
}
.white, h1, h2, h3, h4, h5, h6, button:not([disabled]):hover {
color: #fff;
}
.red {
color: #f34;
flex: 1;
width: 100%;
}
::selection {
background: #ffffff22;
}
.routerProgressBar {
z-index: 999;
}
+41
View File
@@ -0,0 +1,41 @@
// https://nextjs.org/docs/basic-features/layouts#single-shared-layout-with-custom-app
import { useEffect, useState } from "react"
import Head from "next/head"
import Router from "next/router"
import ProgressBar from "@badrap/bar-of-progress"
import Layout from "../components/Layout"
import "./_app.scss"
const progress = new ProgressBar({
size: 2,
color: "var(--accent)",
className: "routerProgressBar",
delay: 0,
})
Router.events.on("routeChangeStart", progress.start)
Router.events.on("routeChangeComplete", progress.finish)
Router.events.on("routeChangeError", progress.finish)
export default function MyApp({ Component, pageProps }) {
const [themeColor, setThemeColor] = useState("#282e31") // --g400 from themePlum
useEffect(() => {
const style = window.getComputedStyle(document.body)
// Same color as navbar
setThemeColor(style.getPropertyValue("--g400"))
}, [])
return <Layout>
<Head>
<meta name="theme-color" content={themeColor} />
</Head>
<Component {...pageProps} />
</Layout>
}
+23
View File
@@ -0,0 +1,23 @@
import Document, { Html, Head, Main, NextScript } from "next/document"
export default class MyDocument extends Document {
render() {
return (
<Html lang="en">
<Head>
<meta charSet="utf-8" />
<meta name="description" content="Decompile code in the browser" />
<link rel="manifest" href="/manifest.json" />
<link rel="shortcut icon" href="/purplefrog.svg" />
<link rel="apple-touch-icon" href="/purplefrog-bg.svg" />
</Head>
<body className="themePlum">
<Main />
<NextScript />
</body>
</Html>
)
}
}
@@ -1,22 +1,21 @@
import { h, Fragment } from "preact"
import { useState, useEffect } from "preact/hooks"
import { useHistory } from "react-router-dom"
import { useState, useEffect } from "react"
import { useRouter } from "next/router"
import { useSWRConfig } from "swr"
import GitHubLoginButton from "./GitHubLoginButton"
import * as api from "../api"
import GitHubLoginButton from "../components/GitHubLoginButton"
import * as api from "../lib/api"
import styles from "./LoginPage.module.css"
import styles from "./login.module.css"
// Handles GitHub OAuth callback
export default function LoginPage() {
const { searchParams } = new URL(document.location.href)
const code = searchParams.get("code")
const next = searchParams.get("next")
const history = useHistory()
const router = useRouter()
const [error, setError] = useState(null)
const { mutate } = useSWRConfig()
const code = (router.query.code ?? "").toString()
const next = (router.query.next ?? "").toString()
useEffect(() => {
if (code) {
@@ -24,7 +23,7 @@ export default function LoginPage() {
api.post("/user", { code }).then((user: api.User) => {
if (next) {
mutate("/user", user)
history.replace(next)
router.replace(next)
} else {
window.close()
}
@@ -33,12 +32,12 @@ export default function LoginPage() {
setError(error)
})
}
}, [code, history, mutate, next])
}, [code, router, mutate, next])
return <>
<main class={styles.container}>
{error ? <div class={styles.card}>
<p class={styles.error}>
<main className={styles.container}>
{error ? <div className={styles.card}>
<p className={styles.error}>
Sign-in error.<br />
{error.message}
</p>
@@ -48,7 +47,7 @@ export default function LoginPage() {
<GitHubLoginButton />
</div> : code ? <>
Signing in...
</> : <div class={styles.card}>
</> : <div className={styles.card}>
<p>
Sign in to decomp.me
</p>
+75
View File
@@ -0,0 +1,75 @@
.container {
display: flex;
flex-direction: column;
gap: 1em;
max-width: 40em;
padding: 1em;
user-select: auto;
}
.rule {
border: 0;
background: var(--g400);
height: 1px;
}
.heading {
padding: 10px;
> h1 {
color: var(--g1900);
font-size: 1.25em;
font-weight: 500;
}
> p {
padding-top: 4px;
color: var(--g1000);
font-size: .9em;
width: 50ch;
}
}
.label {
display: block;
margin: 0;
padding: 10px;
color: var(--g1700);
font-size: .9em;
font-weight: 600;
small {
color: var(--g1000);
font-size: .8em;
}
}
.textInput {
width: 100%;
padding: 8px 10px;
color: var(--g1200);
background: var(--g200);
font: .8em monospace;
border: 1px solid var(--g500);
border-radius: 4px;
}
.editorContainer {
display: flex;
flex-direction: column;
height: 200px;
}
.editor {
width: 100%;
flex: 1;
background: var(--g200);
border: 1px solid var(--g500);
border-radius: 4px;
}
+171
View File
@@ -0,0 +1,171 @@
import { useEffect, useState, useMemo } from "react"
import Head from "next/head"
import { useRouter } from "next/router"
import AsyncButton from "../components/AsyncButton"
import Editor from "../components/Editor"
import Footer from "../components/Footer"
import Nav from "../components/Nav"
import Select from "../components/Select2"
import * as api from "../lib/api"
import styles from "./scratch.module.scss"
function getLabels(asm: string): string[] {
const lines = asm.split("\n")
const labels = []
for (const line of lines) {
const match = line.match(/^\s*glabel\s+([a-zA-Z0-9_]+)\s*$/)
if (match) {
labels.push(match[1])
}
}
return labels
}
export default function NewScratch() {
const [asm, setAsm] = useState("")
const [context, setContext] = useState("")
const [arch, setArch] = useState("")
const router = useRouter()
const arches = api.useArches()
const defaultLabel = useMemo(() => {
const labels = getLabels(asm)
return labels.length > 0 ? labels[labels.length - 1] : null
}, [asm])
const [label, setLabel] = useState<string>("")
// Load fields from localStorage
useEffect(() => {
try {
setLabel(JSON.parse(localStorage["NewScratch.label"] ?? "\"\""))
setAsm(JSON.parse(localStorage["NewScratch.asm"] ?? "\"\""))
setContext(JSON.parse(localStorage["NewScratch.context"] ?? "\"\""))
setArch(JSON.parse(localStorage["NewScratch.arch"] ?? "\"\""))
} catch (error) {
console.warn("bad localStorage", error)
}
}, [])
// Update localStorage
useEffect(() => {
localStorage["NewScratch.label"] = JSON.stringify(label)
localStorage["NewScratch.asm"] = JSON.stringify(asm)
localStorage["NewScratch.context"] = JSON.stringify(context)
localStorage["NewScratch.arch"] = JSON.stringify(arch)
}, [label, asm, context, arch])
if (!arch) {
setArch(Object.keys(arches)[0])
}
const submit = async () => {
try {
const scratch: api.Scratch = await api.post("/scratch", {
target_asm: asm,
context: context || "",
arch,
diff_label: label || defaultLabel || "",
})
localStorage["NewScratch.label"] = ""
localStorage["NewScratch.asm"] = ""
router.push(`/scratch/${scratch.slug}`)
} catch (error) {
if (error?.responseJSON?.as_errors) {
throw new Error(error.responseJSON.as_errors.join("\n"))
} else {
console.error(error)
throw error
}
}
}
return <>
<Head>
<title>New scratch | decomp.me</title>
</Head>
<Nav />
<main className={styles.container}>
<div className={styles.heading}>
<h1>Create a new scratch</h1>
<p>
A scratch is a playground where you can work on matching
a given target function using any compiler options you like.
</p>
</div>
<hr className={styles.rule} />
<div>
<p className={styles.label}>
Architecture
</p>
{/* TODO: custom horizontal <options> */}
<Select
options={arches}
value={arch}
onChange={a => setArch(a)}
/>
</div>
<hr className={styles.rule} />
<div>
<label className={styles.label} htmlFor="label">
Function name <small>(label as it appears in the target asm)</small>
</label>
<input
name="label"
type="text"
value={label}
placeholder={defaultLabel}
onChange={e => setLabel((e.target as HTMLInputElement).value)}
className={styles.textInput}
/>
</div>
<div className={styles.editorContainer}>
<p className={styles.label}>Target assembly <small>(required)</small></p>
<Editor
className={styles.editor}
language="mips"
lineNumbers={false}
value={asm}
onChange={setAsm}
padding={10}
/>
</div>
<div className={styles.editorContainer}>
<p className={styles.label}>
Context <small>(typically generated with m2ctx.py)</small>
</p>
<Editor
className={styles.editor}
language="c"
value={context}
onChange={setContext}
padding={10}
/>
</div>
<hr className={styles.rule} />
<div>
<AsyncButton
primary
disabled={asm.length == 0}
onClick={submit}
errorPlacement="right-center"
>
Create scratch
</AsyncButton>
</div>
</main>
<Footer />
</>
}
@@ -0,0 +1,16 @@
.container {
// centre loading spinner
display: flex;
align-items: center;
justify-content: center;
// fix overflow problems
width: 100%;
height: 0;
overflow: hidden;
}
.loading {
width: 24px;
height: 24px;
}
+51
View File
@@ -0,0 +1,51 @@
import { GetStaticProps } from "next"
import Head from "next/head"
import LoadingSpinner from "../../components/loading.svg"
import Nav from "../../components/Nav"
import Scratch, { nameScratch } from "../../components/scratch/Scratch"
import * as api from "../../lib/api"
import styles from "./[slug].module.scss"
// dynamically render all pages
export async function getStaticPaths() {
return {
paths: [],
fallback: true,
}
}
export const getStaticProps: GetStaticProps = async context => {
const { slug } = context.params
try {
const scratch: api.Scratch = await api.get(`/scratch/${slug}?no_take_ownership`)
return {
props: {
scratch,
},
revalidate: 10,
}
} catch (error) {
return {
notFound: true,
revalidate: 10,
}
}
}
export default function ScratchPage({ scratch }: { scratch?: api.Scratch }) {
return <>
<Head>
<title>{scratch ? nameScratch(scratch) : "Loading scratch"} | decomp.me</title>
</Head>
<Nav />
<main className={styles.container}>
{scratch && <Scratch scratch={scratch} />}
{scratch === undefined && <LoadingSpinner className={styles.loading} />}
</main>
</>
}
@@ -2,7 +2,6 @@
display: flex;
flex-direction: column;
align-items: center;
background: #14161a;
padding-top: 2em;
padding-bottom: 2em;
@@ -17,13 +16,13 @@
.userRow {
display: flex;
flex-direction: row;
align-items: start;
align-items: flex-start;
gap: 1em;
}
.avatar {
width: 4em;
height: 4em;
width: 64px;
height: 64px;
background: #ffffff44;
border-radius: 999px;
}
+128
View File
@@ -0,0 +1,128 @@
import { GetStaticProps } from "next"
import Head from "next/head"
import Image from "next/image"
import { useRouter } from "next/router"
import { MarkGithubIcon } from "@primer/octicons-react"
import useSWR, { useSWRConfig } from "swr"
import AsyncButton from "../../components/AsyncButton"
import Footer from "../../components/Footer"
import Nav from "../../components/Nav"
import * as api from "../../lib/api"
import styles from "./[username].module.css"
// dynamically render all pages
export async function getStaticPaths() {
return {
paths: [],
fallback: "blocking",
}
}
export const getStaticProps: GetStaticProps = async context => {
const { username } = context.params
try {
const user: api.User = await api.get(`/users/${username}`)
return {
props: {
user,
},
revalidate: 10,
}
} catch (error) {
return {
notFound: true,
revalidate: 10,
}
}
}
export default function UserPage({ user: initialUser }: { user: api.User }) {
const { mutate } = useSWRConfig()
const router = useRouter()
const { username } = router.query
const { data: user, error } = useSWR<api.User>(`/users/${initialUser.username}`, api.get, {
fallback: initialUser,
})
const signOut = async () => {
api.post("/user", {})
.then((user: api.AnonymousUser) => {
mutate("/user", user)
mutate(`/users/${username}`)
})
}
if (error)
console.error(error)
if (!user) {
// shouldn't show up in prod because fallback="blocking"
return <>
<Nav />
<main className={styles.pageContainer}>
Loading...
</main>
</>
}
return <>
<Head>
<title>{`${user.name || user.username} | decomp.me`}</title>
</Head>
<Nav />
<main className={styles.pageContainer}>
<section className={styles.userRow}>
{user.avatar_url && <Image
className={styles.avatar}
src={user.avatar_url}
alt="User avatar"
width={64}
height={64}
/>}
<h1 className={styles.name}>
<div>{user.name} {user.is_you && <i>(you)</i>}</div>
<div className={styles.username}>
@{user.username}
{user.github_html_url && <a href={user.github_html_url}>
<MarkGithubIcon size={24} />
</a>}
</div>
</h1>
</section>
{/*<section>
<h2>Scratches</h2>
<ScratchList user={user} />
</section>*/}
{user.is_you && <section>
<AsyncButton onClick={signOut}>
Sign out
</AsyncButton>
</section>}
</main>
<Footer />
</>
}
// TODO: needs backend
/*
export function ScratchList({ user }: { user: api.User }) {
const { data: scratches, error } = useSWR<api.Scratch[]>(`/user/${user.username}/scratches`, api.get)
if (scratches) {
return <ul className={styles.scratchList}>
{scratches.map(scratch => <li key={scratch.id}>
<ScratchLink scratch={scratch} />
</li>)}
</ul>
}
}
*/
-4
View File
@@ -1,4 +0,0 @@
.monacoContainer {
flex-grow: 1;
user-select: initial;
}
-87
View File
@@ -1,87 +0,0 @@
import { h, Fragment } from "preact"
import { useEffect, useState } from "preact/hooks"
import Skeleton from "react-loading-skeleton"
import MonacoEditor, { useMonaco } from "@monaco-editor/react"
import { editor } from "monaco-editor"
import monacoTheme from "./monacoTheme"
import * as customLangauge from "./c"
import styles from "./Editor.module.css"
export type Props = {
language: "c" | "asm",
forceLoading?: boolean,
value?: string,
valueVersion?: string | number,
onChange?: (value: string) => void,
padding?: boolean,
}
export default function Editor({ language, forceLoading, value, valueVersion, onChange, padding }: Props) {
const [isLoading, setIsLoading] = useState(true)
const monaco = useMonaco()
const [model, setModel] = useState<editor.ITextModel>()
useEffect(() => {
if (monaco) {
monaco.editor.defineTheme("custom", monacoTheme)
if (language === "c") {
monaco.languages.register({ id: "custom_c" })
monaco.languages.setMonarchTokensProvider("custom_c", customLangauge.language)
} else if (language === "asm") {
// TODO? possibly not common enough
}
setTimeout(() => setIsLoading(false), 0)
}
}, [monaco]) // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (model && value) {
console.info("Updating editor value because valueVersion changed")
model.setValue(value)
}
}, [valueVersion, model]) // eslint-disable-line react-hooks/exhaustive-deps
return <>
<div style={{ display: (isLoading || forceLoading) ? "none" : "block" }} class={styles.monacoContainer}>
<MonacoEditor
language={language === "c" ? "custom_c" : "custom_asm"}
theme="custom"
defaultValue={value}
options={{
minimap: {
enabled: false,
},
scrollBeyondLastLine: false,
cursorBlinking: "phase",
matchBrackets: "near",
mouseWheelZoom: true,
padding: padding ? { top: 30, bottom: 30 } : {},
fontSize: 13,
}}
onMount={editor => {
setModel(editor.getModel())
}}
onChange={(newValue: string) => {
if (onChange) {
onChange(newValue)
}
}}
/>
</div>
<div style={{
display: (isLoading || forceLoading) ? "block" : "none",
paddingTop: padding ? "2em" : "0",
paddingBottom: padding ? "2em" : "0",
paddingLeft: "2em",
paddingRight: "2em",
background: "#14161a",
height: "100%",
}}>
<Skeleton count={6} height={22} />
</div>
</>
}
@@ -1,95 +0,0 @@
.container {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
cursor: default;
overflow: auto;
}
.card {
border-radius: 1em;
background: #14161a;
box-shadow: 0 0.1em 8px rgba(0, 0, 0, 0.5);
width: calc(var(--s1) * 40);
max-width: 100%;
height: calc(var(--s1) * 30);
overflow: hidden;
display: flex;
flex-direction: column;
}
.heading {
color: #fff;
font-size: 1.25em;
font-weight: 300;
padding: var(--s2);
padding-bottom: 0.2em;
}
.description {
padding: var(--s2);
padding-top: var(--s-2);
}
.targetasm {
flex: 1;
overflow: hidden;
display: flex;
}
/* hide monaco's scrollbar minimap */
.targetasm :global(.decorationsOverviewRuler) {
display: none;
}
.actions {
display: flex;
gap: .5em;
align-items: center;
padding: var(--s2);
}
.actionspacer {
flex: 1;
}
.errormsg {
padding-left: var(--s2);
padding-right: var(--s2);
max-height: 8em;
overflow: auto;
flex: 1;
font-family: monospace;
white-space: pre;
}
.textbox {
background: #00000044;
box-shadow: 0 2px 16px #00000022;
border: 0;
border-radius: .5rem;
overflow: hidden;
}
.textbox * { padding: .6em 1em }
.textbox label {
background:rgba(0, 0, 0, 0.1);
}
.textbox input {
background: inherit;
font: inherit;
border: 0;
font-family: monospace;
font-size: .8rem;
}
-132
View File
@@ -1,132 +0,0 @@
import { h, Fragment } from "preact"
import { useState, useEffect, useMemo } from "preact/hooks"
import { useHistory } from "react-router-dom"
import toast from "react-hot-toast"
import * as api from "../api"
import Nav from "../Nav"
import Editor from "./Editor"
import Select from "../Select"
import { useLocalStorage } from "../hooks"
import styles from "./NewScratch.module.css"
// TODO: use AsyncButton with custom error handler?
export default function NewScratch() {
const [awaitingResponse, setAwaitingResponse] = useState(false)
const [errorMsg, setErrorMsg] = useState("")
const [asm, setAsm] = useLocalStorage("NewScratch.asm", "")
const [context, setContext] = useLocalStorage("NewScratch.context", "")
const [arch, setArch] = useState<string>()
const history = useHistory()
const arches = api.useArches()
const defaultLabel = useMemo(() => {
const labels = getLabels(asm)
return labels.length > 0 ? labels[labels.length - 1] : null
}, [asm])
const [label, setLabel] = useState<string>("")
if (!arch) {
setArch(Object.keys(arches)[0])
}
useEffect(() => {
document.title = "new scratch | decomp.me"
}, [])
const submit = async () => {
setErrorMsg("")
if (awaitingResponse) {
console.warn("create scratch action already in progress")
return
}
try {
setAwaitingResponse(true)
const scratch: api.Scratch = await api.post("/scratch", {
target_asm: asm,
context: context || "",
arch,
diff_label: label || defaultLabel || "",
})
setErrorMsg("")
setAsm("") // Clear the localStorage
history.push(`/scratch/${scratch.slug}`)
toast.success("Scratch created! You may share this url")
} catch (error) {
if (error?.responseJSON?.as_errors) {
setErrorMsg(error.responseJSON.as_errors.join("\n"))
} else {
console.error(error)
setErrorMsg(error.message || error.toString())
}
} finally {
setAwaitingResponse(false)
}
}
return <>
<Nav />
<main class={styles.container}>
<div class={styles.card}>
<h1 class={`${styles.heading}`}>New scratch</h1>
<p class={styles.description}>
Paste your function's target assembly below:
</p>
<div class={styles.targetasm}>
<Editor language="asm" value={asm} onChange={v => setAsm(v)} />
</div>
<p class={styles.description}>
Include any C context (structs, definitions, etc) below:
</p>
<div class={styles.targetasm}>
<Editor language="c" value={context} onChange={v => setContext(v)} />
</div>
{errorMsg && <div class={`red ${styles.errormsg}`}>
{errorMsg}
</div>}
<div class={styles.actions}>
<Select class={styles.compilerSelect} onChange={e => setArch((e.target as HTMLSelectElement).value)}>
{Object.entries(arches).map(([id, name]) => <option key={id} value={id}>{name}</option>)}
</Select>
<div class={styles.textbox}>
<label>Label</label>
<input
type="text"
value={label}
placeholder={defaultLabel}
onChange={e => setLabel((e.target as HTMLInputElement).value)}
/>
</div>
<span class={styles.actionspacer} />
<button disabled={(!asm && arch !== null) || awaitingResponse} onClick={submit}>Create scratch</button>
</div>
</div>
</main>
</>
}
function getLabels(asm: string): string[] {
const lines = asm.split("\n")
const labels = []
for (const line of lines) {
const match = line.match(/^\s*glabel\s+([a-zA-Z0-9_]+)\s*$/)
if (match) {
labels.push(match[1])
}
}
return labels
}
-16
View File
@@ -1,16 +0,0 @@
import { h, Fragment } from "preact"
import { useParams } from "react-router-dom"
import Nav from "../Nav"
import Scratch from "./Scratch"
export default function ScratchPage() {
const { slug } = useParams<{ slug: string }>()
return <>
<Nav />
<main>
<Scratch slug={slug} />
</main>
</>
}
-37
View File
@@ -1,37 +0,0 @@
import { editor } from "monaco-editor"
const theme: editor.IStandaloneThemeData = {
"base": "vs-dark",
"inherit": false,
"rules": [
{ "token": "", "foreground": "ff494c" },
{ "token": "identifier", "foreground": "8599B3" },
{ "token": "delimiter", "foreground": "555f6e" },
{ "token": "operator", "foreground": "7F83FF" },
{ "token": "operator.comparison", "foreground": "FF4ABA" },
{ "token": "comment", "foreground": "465173" },
{ "token": "string", "foreground": "0AFAFA" },
{ "token": "number", "foreground": "0AFAFA" },
{ "token": "function", "foreground": "FF4A98" },
{ "token": "constant.language", "foreground": "0AFAFA" },
{ "token": "constant.character, constant.other", "foreground": "0AFAFA" },
{ "token": "keyword", "foreground": "7F83FF" },
{ "token": "entity.name.class", "foreground": "FF4A98" },
{ "token": "entity.name.function", "foreground": "FF4A98" },
{ "token": "entity.other.attribute-name", "foreground": "FF4A98" },
{ "token": "support.function", "foreground": "45B8FF" },
{ "token": "storage", "foreground": "45B8FF" },
{ "token": "macro", "foreground": "3bff6c" }
],
"colors": {
"editor.foreground": "#8599b3",
"editor.background": "#14161a",
"editor.selectionBackground": "#ffffff22",
"editor.lineHighlightBackground": "#ccccff07",
"editorCursor.foreground": "#c9cbfc",
"editorWhitespace.foreground": "#c9cbfc11",
"editorLineNumber.foreground":"#ccccff31"
}
}
export default theme
-32
View File
@@ -1,32 +0,0 @@
import { h } from "preact"
import { useEffect } from "preact/hooks"
import useSWR from "swr"
import * as api from "../api"
import GitHubLoginButton from "./GitHubLoginButton"
import UserLink from "./UserLink"
export type Props = {
onChange?: (user: api.AnonymousUser | api.User) => void,
}
export default function LoginState({ onChange }: Props) {
const { data, error } = useSWR<api.AnonymousUser | api.User>("/user", api.get)
useEffect(() => {
if (onChange && data) {
onChange(data)
}
}, [data, onChange])
if (error) {
return <div>{error}</div>
} else if (!data) {
// Loading...
return <div />
} else if (data && !api.isAnonUser(data) && data.username) {
return <UserLink user={data} hideYou={true} />
} else {
return <GitHubLoginButton />
}
}
-28
View File
@@ -1,28 +0,0 @@
import { h } from "preact"
import { Link } from "react-router-dom"
import * as api from "../api"
import styles from "./UserLink.module.css"
export type Props = {
user: api.User | api.AnonymousUser,
hideYou?: boolean,
}
export default function UserLink({ user, hideYou }: Props) {
if (api.isAnonUser(user)) {
return <a className={styles.user}>
<span>{user.is_you ? "you" : "anon" }</span>
</a>
} else {
return <Link
to={`/~${user.username}`}
title={`@${user.username}`}
className={styles.user}
>
{user.avatar_url && <img class={styles.avatar} src={user.avatar_url} alt="User avatar" />}
<span>{user.name} {!hideYou && user.is_you && <i>(you)</i>}</span>
</Link>
}
}
-97
View File
@@ -1,97 +0,0 @@
import { h, Fragment } from "preact"
import { useEffect } from "preact/hooks"
import { useParams } from "react-router-dom"
import useSWR, { useSWRConfig } from "swr"
import { MarkGithubIcon } from "@primer/octicons-react"
import * as api from "../api"
import Nav from "../Nav"
import styles from "./UserPage.module.css"
export default function UserPage() {
const { mutate } = useSWRConfig()
const { username } = useParams<{ username: string }>()
const { data, error } = useSWR<api.User>(`/users/${username}`, api.get)
const user = data
useEffect(() => {
document.title = user?.name ? `${user.name} | decomp.me` : `@${username} | decomp.me`
}, [username, user?.name])
const signOut = () => {
api.post("/user", {})
.then((user: api.AnonymousUser) => {
mutate("/user", user)
mutate(`/users/${username}`)
})
.catch(console.error)
}
if (user) {
return <>
<Nav />
<main class={styles.pageContainer}>
<section class={styles.userRow}>
{user.avatar_url && <img
class={styles.avatar}
src={user.avatar_url}
alt="User avatar"
/>}
<h1 class={styles.name}>
<div>{user.name} {user.is_you && <i>(you)</i>}</div>
<div class={styles.username}>
@{user.username}
{user.github_html_url && <a href={user.github_html_url}>
<MarkGithubIcon size={24} />
</a>}
</div>
</h1>
</section>
{/*<section>
<h2>Scratches</h2>
<ScratchList user={user} />
</section>*/}
{user.is_you && <section>
<button class="red" onClick={signOut}>
Sign out
</button>
</section>}
</main>
</>
} else if (error) {
// TODO: better error handling
return <>
<Nav />
<main class={styles.pageContainer}>
{error}
</main>
</>
} else {
// TODO: skeleton
return <>
<Nav />
<main class={styles.pageContainer}>
Loading...
</main>
</>
}
}
// TODO: needs backend
/*
export function ScratchList({ user }: { user: api.User }) {
const { data: scratches, error } = useSWR<api.Scratch[]>(`/user/${user.username}/scratches`, api.get)
if (scratches) {
return <ul class={styles.scratchList}>
{scratches.map(scratch => <li key={scratch.id}>
<ScratchLink scratch={scratch} />
</li>)}
</ul>
}
}
*/
+26 -9
View File
@@ -1,11 +1,28 @@
{
"compilerOptions": {
"jsx": "preserve",
"jsxFactory": "h",
"jsxFragmentFactory": "Fragment",
"module": "esnext",
"moduleResolution": "node",
"noEmit": true,
"lib": ["ESNext"]
},
"compilerOptions": {
"jsx": "preserve",
"module": "esnext",
"moduleResolution": "node",
"noEmit": true,
"lib": [
"ESNext",
"DOM"
],
"target": "es5",
"allowJs": true,
"skipLibCheck": true,
"strict": false,
"forceConsistentCasingInFileNames": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"isolatedModules": true
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx"
],
"exclude": [
"node_modules"
]
}

Some files were not shown because too many files have changed in this diff Show More