mirror of
https://github.com/decompme/decomp.me.git
synced 2026-05-05 14:31:54 -05:00
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:
@@ -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
|
||||
|
||||
@@ -10,3 +10,8 @@ sandbox/
|
||||
.DS_Store
|
||||
.env.*
|
||||
*.log
|
||||
/frontend/.next
|
||||
/frontend/.cache
|
||||
/frontend/public/sw.*
|
||||
/frontend/public/workbox-*
|
||||
/frontend/storybook-static
|
||||
|
||||
Vendored
+12
-1
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
@@ -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$/,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -2,4 +2,4 @@
|
||||
|
||||
yarn install --frozen-lockfile
|
||||
|
||||
yarn start
|
||||
yarn dev
|
||||
|
||||
Vendored
+6
@@ -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.
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 |
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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 |
@@ -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 |
@@ -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",
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
+11
-15
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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 |
@@ -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 {
|
||||
@@ -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",
|
||||
},
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
+3
@@ -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;
|
||||
}
|
||||
+20
-10
@@ -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>
|
||||
)}
|
||||
+16
-14
@@ -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",
|
||||
}
|
||||
|
||||
+15
-17
@@ -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
-1
@@ -1,6 +1,6 @@
|
||||
import { h } from "preact"
|
||||
|
||||
import Select from "../Select"
|
||||
|
||||
import { useCompilersForArch } from "./compilers"
|
||||
|
||||
export const PRESETS = [
|
||||
-1
@@ -1,4 +1,3 @@
|
||||
import { h, Fragment } from "preact"
|
||||
import { Checkbox, FlagSet, FlagOption } from "../CompilerOpts"
|
||||
|
||||
export const name = "EE GCC 2.96"
|
||||
-1
@@ -1,4 +1,3 @@
|
||||
import { h, Fragment } from "preact"
|
||||
import { Checkbox, FlagSet, FlagOption } from "../CompilerOpts"
|
||||
|
||||
export const name = "GCC 2.8.1"
|
||||
-1
@@ -1,4 +1,3 @@
|
||||
import { h, Fragment } from "preact"
|
||||
import { Checkbox, FlagSet, FlagOption } from "../CompilerOpts"
|
||||
|
||||
export const name = "IDO 5.3"
|
||||
-1
@@ -1,4 +1,3 @@
|
||||
import { h, Fragment } from "preact"
|
||||
import { Checkbox, FlagSet, FlagOption } from "../CompilerOpts"
|
||||
|
||||
export const name = "IDO 7.1"
|
||||
+5
-5
@@ -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>
|
||||
}
|
||||
})
|
||||
} </>
|
||||
}
|
||||
@@ -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 |
@@ -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 |
+18
-11
@@ -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>
|
||||
}
|
||||
+2
-5
@@ -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;
|
||||
@@ -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>
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
Vendored
-12
@@ -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,
|
||||
},
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
}
|
||||
*/
|
||||
@@ -1,4 +0,0 @@
|
||||
.monacoContainer {
|
||||
flex-grow: 1;
|
||||
user-select: initial;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 />
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
Reference in New Issue
Block a user