mirror of
https://github.com/decompme/decomp.me.git
synced 2025-12-21 04:49:53 -06:00
@@ -48,10 +48,5 @@
|
||||
"useSortedClasses": "error"
|
||||
}
|
||||
}
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"enabled": false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,168 +1,185 @@
|
||||
const { Compilation } = require("webpack")
|
||||
const { execSync } = require("node:child_process")
|
||||
const { config } = require("dotenv")
|
||||
const { Compilation } = require("webpack");
|
||||
const { execSync } = require("node:child_process");
|
||||
const { config } = require("dotenv");
|
||||
|
||||
for (const envFile of [".env.local", ".env"]) {
|
||||
config({ path: `../${envFile}` })
|
||||
config({ path: `../${envFile}` });
|
||||
}
|
||||
|
||||
const getEnvBool = (key, fallback = false) => {
|
||||
const value = process.env[key]
|
||||
const value = process.env[key];
|
||||
if (value === "false" || value === "0" || value === "off") {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
if (value === "true" || value === "1" || value === "on") {
|
||||
return true
|
||||
return true;
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
return fallback;
|
||||
};
|
||||
|
||||
let git_hash
|
||||
let git_hash;
|
||||
try {
|
||||
git_hash = execSync("git rev-parse HEAD", { stdio: "pipe" }).toString().trim()
|
||||
git_hash = execSync("git rev-parse HEAD", { stdio: "pipe" })
|
||||
.toString()
|
||||
.trim();
|
||||
} catch (error) {
|
||||
console.log("Unable to get git hash, assume running inside Docker")
|
||||
git_hash = "abc123"
|
||||
console.log("Unable to get git hash, assume running inside Docker");
|
||||
git_hash = "abc123";
|
||||
}
|
||||
|
||||
const { withPlausibleProxy } = require("next-plausible")
|
||||
const { withPlausibleProxy } = require("next-plausible");
|
||||
|
||||
const removeImports = require("next-remove-imports")({
|
||||
//test: /node_modules([\s\S]*?)\.(tsx|ts|js|mjs|jsx)$/,
|
||||
//matchImports: "\\.(less|css|scss|sass|styl)$"
|
||||
})
|
||||
});
|
||||
|
||||
const mediaUrl = new URL(process.env.MEDIA_URL ?? "http://localhost")
|
||||
const mediaUrl = new URL(process.env.MEDIA_URL ?? "http://localhost");
|
||||
|
||||
let app = withPlausibleProxy({
|
||||
customDomain: "https://stats.decomp.me",
|
||||
})(removeImports({
|
||||
async redirects() {
|
||||
return [
|
||||
{
|
||||
source: "/scratch",
|
||||
destination: "/",
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: "/scratch/new",
|
||||
destination: "/new",
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: "/settings",
|
||||
destination: "/settings/account",
|
||||
permanent: false,
|
||||
},
|
||||
]
|
||||
},
|
||||
async rewrites() {
|
||||
return []
|
||||
},
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: "/(.*)", // all routes
|
||||
headers: [
|
||||
{
|
||||
key: "X-DNS-Prefetch-Control",
|
||||
value: "on",
|
||||
},
|
||||
{
|
||||
key: "Cross-Origin-Opener-Policy",
|
||||
value: "same-origin",
|
||||
},
|
||||
{
|
||||
key: "Cross-Origin-Embedder-Policy",
|
||||
value: "require-corp",
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
},
|
||||
webpack(config) {
|
||||
config.module.rules.push({
|
||||
test: /\.svg$/,
|
||||
use: ["@svgr/webpack"],
|
||||
})
|
||||
|
||||
// @open-rpc/client-js brings in some dependencies which, in turn, have optional dependencies.
|
||||
// This confuses the heck out of webpack, so tell it should just sub in a CommonJS-style "require" statement
|
||||
// instead (which will fail and trigger the fallback at runtime)
|
||||
// https://stackoverflow.com/questions/58697934/webpack-how-do-you-require-an-optional-dependency-in-bundle-saslprep
|
||||
config.externals.push({
|
||||
"encoding": "commonjs encoding",
|
||||
"bufferutil": "commonjs bufferutil",
|
||||
"utf-8-validate": "commonjs utf-8-validate",
|
||||
})
|
||||
|
||||
// All of the vscode-* packages (jsonrpc, languageserver-protocol, etc.) are distributed as UMD modules.
|
||||
// This also leaves webpack with no idea how to handle require statements.
|
||||
// umd-compat-loader strips away the hedaer UMD adds to allow browsers to parse ES modules
|
||||
// and just treats the importee as an ES module.
|
||||
config.module.rules.push({
|
||||
"test": /node_modules[\\|/](vscode-.*)/,
|
||||
"use": {
|
||||
"loader": "umd-compat-loader",
|
||||
},
|
||||
})
|
||||
|
||||
// XXX: Terser/SWC currently breaks while minifying objdiff's ESM worker in the static directory because
|
||||
// it uses `import.meta.url`. next.js provides no way to control this behavior, of course, so we'll
|
||||
// hook into the optimization stage and mark assets in `static/media/` as already minified.
|
||||
// https://github.com/vercel/next.js/issues/33914
|
||||
// https://github.com/vercel/next.js/discussions/61549
|
||||
config.optimization.minimizer.unshift({
|
||||
apply(compiler) {
|
||||
const pluginName = "SkipWorkerMinify"
|
||||
compiler.hooks.thisCompilation.tap(pluginName, compilation=>{
|
||||
compilation.hooks.processAssets.tap({
|
||||
name: pluginName,
|
||||
stage: Compilation.PROCESS_ASSETS_STAGE_OPTIMIZE_SIZE,
|
||||
}, assets=>{
|
||||
for (const assetName in assets) {
|
||||
if (/^static\/media\//.test(assetName)) {
|
||||
compilation.updateAsset(assetName, assets[assetName], {
|
||||
minimized: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
return config
|
||||
},
|
||||
images: {
|
||||
remotePatterns: [{
|
||||
// Expected 'http' | 'https', received 'http:' at "images.remotePatterns[0].protocol"
|
||||
protocol: mediaUrl.protocol.replace(":", ""),
|
||||
hostname: mediaUrl.hostname,
|
||||
port: mediaUrl.port,
|
||||
pathname: "/**",
|
||||
})(
|
||||
removeImports({
|
||||
async redirects() {
|
||||
return [
|
||||
{
|
||||
source: "/scratch",
|
||||
destination: "/",
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: "/scratch/new",
|
||||
destination: "/new",
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: "/settings",
|
||||
destination: "/settings/account",
|
||||
permanent: false,
|
||||
},
|
||||
];
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "avatars.githubusercontent.com",
|
||||
port: "",
|
||||
pathname: "/**",
|
||||
async rewrites() {
|
||||
return [];
|
||||
},
|
||||
],
|
||||
unoptimized: !getEnvBool("FRONTEND_USE_IMAGE_PROXY"),
|
||||
},
|
||||
swcMinify: true,
|
||||
env: {
|
||||
// XXX: don't need 'NEXT_PUBLIC_' prefix here; we could just use 'API_BASE' and 'GITHUB_CLIENT_ID'
|
||||
// See note at top of https://nextjs.org/docs/api-reference/next.config.js/environment-variables for more information
|
||||
NEXT_PUBLIC_API_BASE: process.env.API_BASE,
|
||||
NEXT_PUBLIC_GITHUB_CLIENT_ID: process.env.GITHUB_CLIENT_ID,
|
||||
NEXT_PUBLIC_COMMIT_HASH: git_hash,
|
||||
},
|
||||
}))
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: "/(.*)", // all routes
|
||||
headers: [
|
||||
{
|
||||
key: "X-DNS-Prefetch-Control",
|
||||
value: "on",
|
||||
},
|
||||
{
|
||||
key: "Cross-Origin-Opener-Policy",
|
||||
value: "same-origin",
|
||||
},
|
||||
{
|
||||
key: "Cross-Origin-Embedder-Policy",
|
||||
value: "require-corp",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
webpack(config) {
|
||||
config.module.rules.push({
|
||||
test: /\.svg$/,
|
||||
use: ["@svgr/webpack"],
|
||||
});
|
||||
|
||||
// @open-rpc/client-js brings in some dependencies which, in turn, have optional dependencies.
|
||||
// This confuses the heck out of webpack, so tell it should just sub in a CommonJS-style "require" statement
|
||||
// instead (which will fail and trigger the fallback at runtime)
|
||||
// https://stackoverflow.com/questions/58697934/webpack-how-do-you-require-an-optional-dependency-in-bundle-saslprep
|
||||
config.externals.push({
|
||||
encoding: "commonjs encoding",
|
||||
bufferutil: "commonjs bufferutil",
|
||||
"utf-8-validate": "commonjs utf-8-validate",
|
||||
});
|
||||
|
||||
// All of the vscode-* packages (jsonrpc, languageserver-protocol, etc.) are distributed as UMD modules.
|
||||
// This also leaves webpack with no idea how to handle require statements.
|
||||
// umd-compat-loader strips away the hedaer UMD adds to allow browsers to parse ES modules
|
||||
// and just treats the importee as an ES module.
|
||||
config.module.rules.push({
|
||||
test: /node_modules[\\|/](vscode-.*)/,
|
||||
use: {
|
||||
loader: "umd-compat-loader",
|
||||
},
|
||||
});
|
||||
|
||||
// XXX: Terser/SWC currently breaks while minifying objdiff's ESM worker in the static directory because
|
||||
// it uses `import.meta.url`. next.js provides no way to control this behavior, of course, so we'll
|
||||
// hook into the optimization stage and mark assets in `static/media/` as already minified.
|
||||
// https://github.com/vercel/next.js/issues/33914
|
||||
// https://github.com/vercel/next.js/discussions/61549
|
||||
config.optimization.minimizer.unshift({
|
||||
apply(compiler) {
|
||||
const pluginName = "SkipWorkerMinify";
|
||||
compiler.hooks.thisCompilation.tap(
|
||||
pluginName,
|
||||
(compilation) => {
|
||||
compilation.hooks.processAssets.tap(
|
||||
{
|
||||
name: pluginName,
|
||||
stage: Compilation.PROCESS_ASSETS_STAGE_OPTIMIZE_SIZE,
|
||||
},
|
||||
(assets) => {
|
||||
for (const assetName in assets) {
|
||||
if (
|
||||
/^static\/media\//.test(assetName)
|
||||
) {
|
||||
compilation.updateAsset(
|
||||
assetName,
|
||||
assets[assetName],
|
||||
{
|
||||
minimized: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
return config;
|
||||
},
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
// Expected 'http' | 'https', received 'http:' at "images.remotePatterns[0].protocol"
|
||||
protocol: mediaUrl.protocol.replace(":", ""),
|
||||
hostname: mediaUrl.hostname,
|
||||
port: mediaUrl.port,
|
||||
pathname: "/**",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "avatars.githubusercontent.com",
|
||||
port: "",
|
||||
pathname: "/**",
|
||||
},
|
||||
],
|
||||
unoptimized: !getEnvBool("FRONTEND_USE_IMAGE_PROXY"),
|
||||
},
|
||||
swcMinify: true,
|
||||
env: {
|
||||
// XXX: don't need 'NEXT_PUBLIC_' prefix here; we could just use 'API_BASE' and 'GITHUB_CLIENT_ID'
|
||||
// See note at top of https://nextjs.org/docs/api-reference/next.config.js/environment-variables for more information
|
||||
NEXT_PUBLIC_API_BASE: process.env.API_BASE,
|
||||
NEXT_PUBLIC_GITHUB_CLIENT_ID: process.env.GITHUB_CLIENT_ID,
|
||||
NEXT_PUBLIC_COMMIT_HASH: git_hash,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
if (process.env.ANALYZE === "true") {
|
||||
app = require("@next/bundle-analyzer")(app)
|
||||
app = require("@next/bundle-analyzer")(app);
|
||||
}
|
||||
|
||||
module.exports = app
|
||||
module.exports = app;
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
module.exports = {
|
||||
plugins: [
|
||||
"tailwindcss",
|
||||
"autoprefixer",
|
||||
"cssnano",
|
||||
],
|
||||
}
|
||||
plugins: ["tailwindcss", "autoprefixer", "cssnano"],
|
||||
};
|
||||
|
||||
@@ -1,34 +1,42 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react"
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import Link from "next/link"
|
||||
import Link from "next/link";
|
||||
|
||||
import { useStats } from "@/lib/api"
|
||||
import { useStats } from "@/lib/api";
|
||||
|
||||
function Stat({ children, href }: { children: ReactNode, href?: string }) {
|
||||
function Stat({ children, href }: { children: ReactNode; href?: string }) {
|
||||
if (href) {
|
||||
return <Link href={href} className="hover:underline active:translate-y-px">
|
||||
{children}
|
||||
</Link>
|
||||
return (
|
||||
<Link href={href} className="hover:underline active:translate-y-px">
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return <span>
|
||||
{children}
|
||||
</span>
|
||||
return <span>{children}</span>;
|
||||
}
|
||||
|
||||
export default function SiteStats() {
|
||||
const stats = useStats()
|
||||
const stats = useStats();
|
||||
|
||||
if (!stats) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
return <p className="inline-flex gap-8 text-gray-11 text-xs md:gap-16">
|
||||
<Stat>{stats.scratch_count.toLocaleString()} scratches created</Stat>
|
||||
<Stat href="https://stats.decomp.me/decomp.me">{stats.profile_count.toLocaleString()} unique visitors</Stat>
|
||||
<Stat>{stats.github_user_count.toLocaleString()} users signed up</Stat>
|
||||
<Stat>{stats.asm_count.toLocaleString()} asm globs submitted</Stat>
|
||||
</p>
|
||||
return (
|
||||
<p className="inline-flex gap-8 text-gray-11 text-xs md:gap-16">
|
||||
<Stat>
|
||||
{stats.scratch_count.toLocaleString()} scratches created
|
||||
</Stat>
|
||||
<Stat href="https://stats.decomp.me/decomp.me">
|
||||
{stats.profile_count.toLocaleString()} unique visitors
|
||||
</Stat>
|
||||
<Stat>
|
||||
{stats.github_user_count.toLocaleString()} users signed up
|
||||
</Stat>
|
||||
<Stat>{stats.asm_count.toLocaleString()} asm globs submitted</Stat>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,55 +1,62 @@
|
||||
import { headers } from "next/headers"
|
||||
import Link from "next/link"
|
||||
import { headers } from "next/headers";
|
||||
import Link from "next/link";
|
||||
|
||||
import { ArrowRightIcon } from "@primer/octicons-react"
|
||||
import { ArrowRightIcon } from "@primer/octicons-react";
|
||||
|
||||
import Button from "@/components/Button"
|
||||
import GitHubLoginButton from "@/components/GitHubLoginButton"
|
||||
import ScrollingPlatformIcons from "@/components/PlatformSelect/ScrollingPlatformIcons"
|
||||
import Button from "@/components/Button";
|
||||
import GitHubLoginButton from "@/components/GitHubLoginButton";
|
||||
import ScrollingPlatformIcons from "@/components/PlatformSelect/ScrollingPlatformIcons";
|
||||
|
||||
import SiteStats from "./SiteStats"
|
||||
import SiteStats from "./SiteStats";
|
||||
|
||||
export const SITE_DESCRIPTION = "A collaborative reverse-engineering platform for working on decompilation projects with others to learn about how your favorite games work."
|
||||
export const SITE_DESCRIPTION =
|
||||
"A collaborative reverse-engineering platform for working on decompilation projects with others to learn about how your favorite games work.";
|
||||
|
||||
export default function WelcomeInfo() {
|
||||
const saveDataEnabled = headers().get("Save-Data") === "on"
|
||||
const saveDataEnabled = headers().get("Save-Data") === "on";
|
||||
|
||||
return <div className="relative overflow-x-hidden p-2">
|
||||
{!saveDataEnabled && <div className="-z-10 absolute top-14 hidden w-full opacity-80 sm:block">
|
||||
<ScrollingPlatformIcons />
|
||||
<div
|
||||
className="absolute top-0 size-full"
|
||||
style={{
|
||||
// Gradient to only show icons in the middle
|
||||
background: "linear-gradient(to right, transparent, hsl(var(--color-mauve1)) 40%, hsl(var(--color-mauve1)) 60%, transparent)",
|
||||
}}
|
||||
/>
|
||||
</div>}
|
||||
<div className="text-center text-lg">
|
||||
<h1
|
||||
className="mx-auto w-full max-w-lg font-extrabold text-4xl text-gray-12 !md:leading-[0.8] md:max-w-3xl md:text-6xl"
|
||||
style={{
|
||||
// Shadow to make text more readable on the background
|
||||
textShadow: "0 1px 0.3rem hsl(var(--color-mauve10) / 0.4)",
|
||||
}}
|
||||
>
|
||||
Collaboratively decompile code in your browser.
|
||||
</h1>
|
||||
<p className="mx-auto my-6 w-full max-w-screen-sm text-gray-11 leading-tight">
|
||||
{SITE_DESCRIPTION}
|
||||
</p>
|
||||
<div className="flex flex-col items-center justify-center gap-2 md:flex-row">
|
||||
<Link href="/new">
|
||||
<Button primary>
|
||||
Start decomping
|
||||
<ArrowRightIcon />
|
||||
</Button>
|
||||
</Link>
|
||||
<GitHubLoginButton />
|
||||
</div>
|
||||
<div className="my-6 hidden sm:block">
|
||||
<SiteStats />
|
||||
return (
|
||||
<div className="relative overflow-x-hidden p-2">
|
||||
{!saveDataEnabled && (
|
||||
<div className="-z-10 absolute top-14 hidden w-full opacity-80 sm:block">
|
||||
<ScrollingPlatformIcons />
|
||||
<div
|
||||
className="absolute top-0 size-full"
|
||||
style={{
|
||||
// Gradient to only show icons in the middle
|
||||
background:
|
||||
"linear-gradient(to right, transparent, hsl(var(--color-mauve1)) 40%, hsl(var(--color-mauve1)) 60%, transparent)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="text-center text-lg">
|
||||
<h1
|
||||
className="mx-auto w-full max-w-lg font-extrabold text-4xl text-gray-12 !md:leading-[0.8] md:max-w-3xl md:text-6xl"
|
||||
style={{
|
||||
// Shadow to make text more readable on the background
|
||||
textShadow:
|
||||
"0 1px 0.3rem hsl(var(--color-mauve10) / 0.4)",
|
||||
}}
|
||||
>
|
||||
Collaboratively decompile code in your browser.
|
||||
</h1>
|
||||
<p className="mx-auto my-6 w-full max-w-screen-sm text-gray-11 leading-tight">
|
||||
{SITE_DESCRIPTION}
|
||||
</p>
|
||||
<div className="flex flex-col items-center justify-center gap-2 md:flex-row">
|
||||
<Link href="/new">
|
||||
<Button primary>
|
||||
Start decomping
|
||||
<ArrowRightIcon />
|
||||
</Button>
|
||||
</Link>
|
||||
<GitHubLoginButton />
|
||||
</div>
|
||||
<div className="my-6 hidden sm:block">
|
||||
<SiteStats />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,68 +1,89 @@
|
||||
import { LinkExternalIcon } from "@primer/octicons-react"
|
||||
import { LinkExternalIcon } from "@primer/octicons-react";
|
||||
|
||||
import GhostButton from "@/components/GhostButton"
|
||||
import UserAvatar from "@/components/user/UserAvatar"
|
||||
import UserMention, { type GithubUser, getUserName } from "@/components/user/UserMention"
|
||||
import { get } from "@/lib/api/request"
|
||||
import type { User } from "@/lib/api/types"
|
||||
import GhostButton from "@/components/GhostButton";
|
||||
import UserAvatar from "@/components/user/UserAvatar";
|
||||
import UserMention, {
|
||||
type GithubUser,
|
||||
getUserName,
|
||||
} from "@/components/user/UserMention";
|
||||
import { get } from "@/lib/api/request";
|
||||
import type { User } from "@/lib/api/types";
|
||||
|
||||
interface GitHubContributor {
|
||||
login: string
|
||||
contributions: number
|
||||
login: string;
|
||||
contributions: number;
|
||||
}
|
||||
|
||||
export type Contributor = User | GithubUser
|
||||
export type Contributor = User | GithubUser;
|
||||
|
||||
/** Gets the list of contributor usernames for the repo from GitHub. */
|
||||
export async function getContributorUsernames(): Promise<string[]> {
|
||||
const req = await fetch("https://api.github.com/repos/decompme/decomp.me/contributors?page_size=100", {
|
||||
cache: "force-cache",
|
||||
})
|
||||
const req = await fetch(
|
||||
"https://api.github.com/repos/decompme/decomp.me/contributors?page_size=100",
|
||||
{
|
||||
cache: "force-cache",
|
||||
},
|
||||
);
|
||||
|
||||
if (!req.ok) {
|
||||
console.warn("failed to fetch contributors:", await req.text())
|
||||
return ["ethteck", "nanaian"]
|
||||
console.warn("failed to fetch contributors:", await req.text());
|
||||
return ["ethteck", "nanaian"];
|
||||
}
|
||||
|
||||
const contributors = await req.json() as GitHubContributor[]
|
||||
contributors.sort((a, b) => b.contributions - a.contributions)
|
||||
return contributors.map((contributor) => contributor.login)
|
||||
const contributors = (await req.json()) as GitHubContributor[];
|
||||
contributors.sort((a, b) => b.contributions - a.contributions);
|
||||
return contributors.map((contributor) => contributor.login);
|
||||
}
|
||||
|
||||
export async function usernameToContributor(username: string): Promise<Contributor> {
|
||||
export async function usernameToContributor(
|
||||
username: string,
|
||||
): Promise<Contributor> {
|
||||
try {
|
||||
// Try and get decomp.me information if they have an account
|
||||
const user: User = await get(`/users/${username}`)
|
||||
return user
|
||||
const user: User = await get(`/users/${username}`);
|
||||
return user;
|
||||
} catch (error) {
|
||||
// Fall back to GitHub information
|
||||
return { login: username }
|
||||
return { login: username };
|
||||
}
|
||||
}
|
||||
|
||||
export function ContributorItem({ contributor }: { contributor: Contributor }) {
|
||||
return <li className="flex items-center p-2 md:w-1/3">
|
||||
{!("login" in contributor) && <UserAvatar user={contributor} className="mr-1.5 size-6" />}
|
||||
<UserMention user={contributor} />
|
||||
</li>
|
||||
return (
|
||||
<li className="flex items-center p-2 md:w-1/3">
|
||||
{!("login" in contributor) && (
|
||||
<UserAvatar user={contributor} className="mr-1.5 size-6" />
|
||||
)}
|
||||
<UserMention user={contributor} />
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ContributorsList({ contributors }: { contributors: Contributor[] }) {
|
||||
export default function ContributorsList({
|
||||
contributors,
|
||||
}: { contributors: Contributor[] }) {
|
||||
if (!contributors.length) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
return <div className="py-4">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h3 className="font-medium text-gray-12 text-lg tracking-tight md:text-2xl">
|
||||
Contributors
|
||||
</h3>
|
||||
<GhostButton href="https://github.com/decompme/decomp.me/graphs/contributors">
|
||||
View on GitHub <LinkExternalIcon />
|
||||
</GhostButton>
|
||||
return (
|
||||
<div className="py-4">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h3 className="font-medium text-gray-12 text-lg tracking-tight md:text-2xl">
|
||||
Contributors
|
||||
</h3>
|
||||
<GhostButton href="https://github.com/decompme/decomp.me/graphs/contributors">
|
||||
View on GitHub <LinkExternalIcon />
|
||||
</GhostButton>
|
||||
</div>
|
||||
<ul className="flex flex-wrap">
|
||||
{contributors.map((contributor) => (
|
||||
<ContributorItem
|
||||
key={getUserName(contributor)}
|
||||
contributor={contributor}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<ul className="flex flex-wrap">
|
||||
{contributors.map(contributor => <ContributorItem key={getUserName(contributor)} contributor={contributor} />)}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
import GhostButton from "@/components/GhostButton"
|
||||
import GhostButton from "@/components/GhostButton";
|
||||
|
||||
export type Props = {
|
||||
links: { [key: string]: string }
|
||||
}
|
||||
links: { [key: string]: string };
|
||||
};
|
||||
|
||||
export default function LinkList({ links }: Props) {
|
||||
return <ul className="flex flex-wrap">
|
||||
{Object.entries(links).map(([name, url]) => {
|
||||
return <li key={name} className="p-2">
|
||||
<GhostButton href={url}>
|
||||
{name}
|
||||
</GhostButton>
|
||||
</li>
|
||||
})}
|
||||
</ul>
|
||||
return (
|
||||
<ul className="flex flex-wrap">
|
||||
{Object.entries(links).map(([name, url]) => {
|
||||
return (
|
||||
<li key={name} className="p-2">
|
||||
<GhostButton href={url}>{name}</GhostButton>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,99 +1,122 @@
|
||||
import UserMention from "@/components/user/UserMention"
|
||||
import { get } from "@/lib/api/request"
|
||||
import type { User } from "@/lib/api/types"
|
||||
import UserMention from "@/components/user/UserMention";
|
||||
import { get } from "@/lib/api/request";
|
||||
import type { User } from "@/lib/api/types";
|
||||
|
||||
import ContributorsList, { getContributorUsernames, usernameToContributor } from "./ContributorsList"
|
||||
import LinkList from "./LinkList"
|
||||
import ContributorsList, {
|
||||
getContributorUsernames,
|
||||
usernameToContributor,
|
||||
} from "./ContributorsList";
|
||||
import LinkList from "./LinkList";
|
||||
|
||||
const MAINTAINER_USERNAMES = ["ethteck", "bates64"]
|
||||
const MAINTAINER_USERNAMES = ["ethteck", "bates64"];
|
||||
const OTHER_PROJECTS = {
|
||||
"asm-differ": "https://github.com/simonlindholm/asm-differ",
|
||||
"m2c": "https://github.com/matt-kempster/m2c",
|
||||
"psyq-obj-parser": "https://github.com/grumpycoders/pcsx-redux/tree/main/tools/psyq-obj-parser",
|
||||
"Django": "https://www.djangoproject.com/",
|
||||
m2c: "https://github.com/matt-kempster/m2c",
|
||||
"psyq-obj-parser":
|
||||
"https://github.com/grumpycoders/pcsx-redux/tree/main/tools/psyq-obj-parser",
|
||||
Django: "https://www.djangoproject.com/",
|
||||
"Django REST Framework": "https://www.django-rest-framework.org/",
|
||||
"Next.js": "https://nextjs.org/",
|
||||
"React": "https://reactjs.org/",
|
||||
React: "https://reactjs.org/",
|
||||
"Tailwind CSS": "https://tailwindcss.com/",
|
||||
"SWR": "https://swr.vercel.app/",
|
||||
}
|
||||
SWR: "https://swr.vercel.app/",
|
||||
};
|
||||
const ICON_SOURCES = {
|
||||
"Octicons": "https://primer.style/octicons/",
|
||||
Octicons: "https://primer.style/octicons/",
|
||||
"file-icons/icons": "https://github.com/file-icons/icons",
|
||||
"coreui/coreui-icons": "https://github.com/coreui/coreui-icons",
|
||||
"New Fontendo 23DSi Lite XL": "https://www.deviantart.com/maxigamer/art/FONT-New-Fontendo-23DSi-Lite-XL-DOWNLOAD-ZIP-552834059",
|
||||
"GBA SVG by Andrew Vester from NounProject.com": "https://thenounproject.com/icon/gameboy-advanced-752507/",
|
||||
"Happy Mac by NiloGlock": "https://commons.wikimedia.org/wiki/File:Happy_Mac.svg",
|
||||
"Tiger-like-x by Althepal": "https://commons.wikimedia.org/wiki/File:Tiger-like-x.svg",
|
||||
"Saturn by JustDanPatrick": "https://upload.wikimedia.org/wikipedia/commons/archive/7/78/20220518145749%21Sega_Saturn_Black_Logo.svg",
|
||||
"Dreamcast by Sega": "https://en.wikipedia.org/wiki/File:Dreamcast_logo.svg",
|
||||
"MS-DOS by Microsoft": "https://commons.wikimedia.org/wiki/File:Msdos-icon.svg",
|
||||
"Windows 9x by Microsoft": "https://commons.wikimedia.org/wiki/File:Windows_Logo_(1992-2001).svg",
|
||||
"New Fontendo 23DSi Lite XL":
|
||||
"https://www.deviantart.com/maxigamer/art/FONT-New-Fontendo-23DSi-Lite-XL-DOWNLOAD-ZIP-552834059",
|
||||
"GBA SVG by Andrew Vester from NounProject.com":
|
||||
"https://thenounproject.com/icon/gameboy-advanced-752507/",
|
||||
"Happy Mac by NiloGlock":
|
||||
"https://commons.wikimedia.org/wiki/File:Happy_Mac.svg",
|
||||
"Tiger-like-x by Althepal":
|
||||
"https://commons.wikimedia.org/wiki/File:Tiger-like-x.svg",
|
||||
"Saturn by JustDanPatrick":
|
||||
"https://upload.wikimedia.org/wikipedia/commons/archive/7/78/20220518145749%21Sega_Saturn_Black_Logo.svg",
|
||||
"Dreamcast by Sega":
|
||||
"https://en.wikipedia.org/wiki/File:Dreamcast_logo.svg",
|
||||
"MS-DOS by Microsoft":
|
||||
"https://commons.wikimedia.org/wiki/File:Msdos-icon.svg",
|
||||
"Windows 9x by Microsoft":
|
||||
"https://commons.wikimedia.org/wiki/File:Windows_Logo_(1992-2001).svg",
|
||||
"PerSPire Font by Sean Liew": "https://www.fontspace.com/sean-liew",
|
||||
}
|
||||
};
|
||||
|
||||
type Contributor = {
|
||||
type: "decompme"
|
||||
user: User
|
||||
} | {
|
||||
type: "github"
|
||||
user: { login: string }
|
||||
}
|
||||
type Contributor =
|
||||
| {
|
||||
type: "decompme";
|
||||
user: User;
|
||||
}
|
||||
| {
|
||||
type: "github";
|
||||
user: { login: string };
|
||||
};
|
||||
|
||||
async function getContributor(username: string): Promise<Contributor> {
|
||||
try {
|
||||
// Try and get decomp.me information if they have an account
|
||||
const user: User = await get(`/users/${username}`)
|
||||
const user: User = await get(`/users/${username}`);
|
||||
return {
|
||||
type: "decompme",
|
||||
user,
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
// Fall back to GitHub information
|
||||
// No need to ask their API for data since we just need the username
|
||||
return {
|
||||
type: "github",
|
||||
user: { login: username },
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function Contributor({ contributor }: { contributor: Contributor }) {
|
||||
return <UserMention user={contributor.user} />
|
||||
return <UserMention user={contributor.user} />;
|
||||
}
|
||||
|
||||
export const metadata = {
|
||||
title: "Credits",
|
||||
}
|
||||
};
|
||||
|
||||
export default async function Page() {
|
||||
const maintainers = await Promise.all(MAINTAINER_USERNAMES.map(getContributor))
|
||||
const contributors = await getContributorUsernames().then(usernames => Promise.all(usernames.map(usernameToContributor)))
|
||||
const maintainers = await Promise.all(
|
||||
MAINTAINER_USERNAMES.map(getContributor),
|
||||
);
|
||||
const contributors = await getContributorUsernames().then((usernames) =>
|
||||
Promise.all(usernames.map(usernameToContributor)),
|
||||
);
|
||||
|
||||
return <main>
|
||||
<div className="mx-auto max-w-prose p-4 pb-2 text-justify text-base leading-normal">
|
||||
<h1 className="font-semibold text-2xl text-gray-12 tracking-tight md:text-3xl">
|
||||
Credits
|
||||
</h1>
|
||||
<p className="py-4">
|
||||
decomp.me is maintained by <Contributor contributor={maintainers[0]} /> and <Contributor contributor={maintainers[1]} />.
|
||||
</p>
|
||||
<div className="my-4 border-gray-6 border-y">
|
||||
<ContributorsList contributors={contributors} />
|
||||
</div>
|
||||
<div className="py-4">
|
||||
<h3 className="font-medium text-gray-12 text-lg tracking-tight md:text-2xl">
|
||||
Acknowledgements
|
||||
</h3>
|
||||
<p className="my-2">
|
||||
decomp.me is built on top of many other open source projects, including:
|
||||
return (
|
||||
<main>
|
||||
<div className="mx-auto max-w-prose p-4 pb-2 text-justify text-base leading-normal">
|
||||
<h1 className="font-semibold text-2xl text-gray-12 tracking-tight md:text-3xl">
|
||||
Credits
|
||||
</h1>
|
||||
<p className="py-4">
|
||||
decomp.me is maintained by{" "}
|
||||
<Contributor contributor={maintainers[0]} /> and{" "}
|
||||
<Contributor contributor={maintainers[1]} />.
|
||||
</p>
|
||||
<LinkList links={OTHER_PROJECTS} />
|
||||
<p className="my-2">
|
||||
We also use icons from the following sources:
|
||||
</p>
|
||||
<LinkList links={ICON_SOURCES} />
|
||||
<div className="my-4 border-gray-6 border-y">
|
||||
<ContributorsList contributors={contributors} />
|
||||
</div>
|
||||
<div className="py-4">
|
||||
<h3 className="font-medium text-gray-12 text-lg tracking-tight md:text-2xl">
|
||||
Acknowledgements
|
||||
</h3>
|
||||
<p className="my-2">
|
||||
decomp.me is built on top of many other open source
|
||||
projects, including:
|
||||
</p>
|
||||
<LinkList links={OTHER_PROJECTS} />
|
||||
<p className="my-2">
|
||||
We also use icons from the following sources:
|
||||
</p>
|
||||
<LinkList links={ICON_SOURCES} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Rexport app error page to include the layout
|
||||
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import ErrorPage from "../error"
|
||||
import ErrorPage from "../error";
|
||||
|
||||
export default ErrorPage
|
||||
export default ErrorPage;
|
||||
|
||||
@@ -1,128 +1,205 @@
|
||||
import type { ReactNode } from "react"
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import Link from "next/link"
|
||||
import Link from "next/link";
|
||||
|
||||
import Frog from "@/components/Nav/frog.svg"
|
||||
import Frog from "@/components/Nav/frog.svg";
|
||||
|
||||
const subtitle = "mt-8 text-xl font-semibold tracking-tight text-gray-11"
|
||||
const subtitle = "mt-8 text-xl font-semibold tracking-tight text-gray-11";
|
||||
|
||||
function FaqLink({ children, href }: { children: ReactNode, href?: string }) {
|
||||
return <Link href={href} className="text-blue-11 hover:underline active:translate-y-px">
|
||||
{children}
|
||||
</Link>
|
||||
function FaqLink({ children, href }: { children: ReactNode; href?: string }) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className="text-blue-11 hover:underline active:translate-y-px"
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function DiscordLink() {
|
||||
return <FaqLink href="https://discord.gg/sutqNShRRs">Discord Server</FaqLink>
|
||||
return (
|
||||
<FaqLink href="https://discord.gg/sutqNShRRs">Discord Server</FaqLink>
|
||||
);
|
||||
}
|
||||
|
||||
export const metadata = {
|
||||
title: "Frequently Asked Questions",
|
||||
}
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
return <main className="mx-auto max-w-prose p-4 pb-2 text-justify text-base leading-normal">
|
||||
<h1 className="font-semibold text-2xl text-gray-12 tracking-tight md:text-3xl">
|
||||
Frequently Asked Questions
|
||||
</h1>
|
||||
return (
|
||||
<main className="mx-auto max-w-prose p-4 pb-2 text-justify text-base leading-normal">
|
||||
<h1 className="font-semibold text-2xl text-gray-12 tracking-tight md:text-3xl">
|
||||
Frequently Asked Questions
|
||||
</h1>
|
||||
|
||||
<h2 className={subtitle}>What is decomp.me?</h2>
|
||||
<p className="my-4">
|
||||
decomp.me is an interactive web-based platform where users can collaboratively decompile assembly code snippets by writing matching code.
|
||||
</p>
|
||||
<p className="my-4">
|
||||
It is an <FaqLink href="https://www.github.com/decomp.me">open-source project</FaqLink> run by volunteers in their free time.
|
||||
</p>
|
||||
<h2 className={subtitle}>What is decomp.me?</h2>
|
||||
<p className="my-4">
|
||||
decomp.me is an interactive web-based platform where users can
|
||||
collaboratively decompile assembly code snippets by writing
|
||||
matching code.
|
||||
</p>
|
||||
<p className="my-4">
|
||||
It is an{" "}
|
||||
<FaqLink href="https://www.github.com/decomp.me">
|
||||
open-source project
|
||||
</FaqLink>{" "}
|
||||
run by volunteers in their free time.
|
||||
</p>
|
||||
|
||||
<h2 className={subtitle}>What do you mean by "matching"?</h2>
|
||||
<p className="my-4">
|
||||
decomp.me is designed for users who are working on matching decompilation projects, where the goal is to produce high-level code like C or C++ that perfectly replicates the original assembly upon compilation.
|
||||
</p>
|
||||
<p className="my-4">
|
||||
This is a time- and labor-intensive process. To produce matching assembly, one usually needs the original compiler, assembler, and flags that were used to produce the original binary.
|
||||
Most importantly, the code has to be written in such a way that the compiler will generate assembly that is identical to what is being compared against.
|
||||
Writing matching code is a skill that takes time to learn, but it can be very rewarding and addictive.
|
||||
</p>
|
||||
<h2 className={subtitle}>What do you mean by "matching"?</h2>
|
||||
<p className="my-4">
|
||||
decomp.me is designed for users who are working on matching
|
||||
decompilation projects, where the goal is to produce high-level
|
||||
code like C or C++ that perfectly replicates the original
|
||||
assembly upon compilation.
|
||||
</p>
|
||||
<p className="my-4">
|
||||
This is a time- and labor-intensive process. To produce matching
|
||||
assembly, one usually needs the original compiler, assembler,
|
||||
and flags that were used to produce the original binary. Most
|
||||
importantly, the code has to be written in such a way that the
|
||||
compiler will generate assembly that is identical to what is
|
||||
being compared against. Writing matching code is a skill that
|
||||
takes time to learn, but it can be very rewarding and addictive.
|
||||
</p>
|
||||
|
||||
<h2 className={subtitle}>What's a scratch?</h2>
|
||||
<p className="my-4">
|
||||
A scratch is a workspace for exploring a compiler's codegen, similar to <FaqLink href="https://godbolt.org/">Compiler Explorer</FaqLink>.
|
||||
A scratch consists of the target assembly, input source code, and input context code, as well as additional settings and metadata.
|
||||
Most scratches contain a single function - i.e. the function that you are trying to match.
|
||||
Each scratch has a unique link that can be shared with others. Scratches have a "family" of forks, which are copies of the original scratch.
|
||||
</p>
|
||||
<h2 className={subtitle}>What's a scratch?</h2>
|
||||
<p className="my-4">
|
||||
A scratch is a workspace for exploring a compiler's codegen,
|
||||
similar to{" "}
|
||||
<FaqLink href="https://godbolt.org/">Compiler Explorer</FaqLink>
|
||||
. A scratch consists of the target assembly, input source code,
|
||||
and input context code, as well as additional settings and
|
||||
metadata. Most scratches contain a single function - i.e. the
|
||||
function that you are trying to match. Each scratch has a unique
|
||||
link that can be shared with others. Scratches have a "family"
|
||||
of forks, which are copies of the original scratch.
|
||||
</p>
|
||||
|
||||
<h2 className={subtitle}>What's the context for?</h2>
|
||||
<p className="my-4">
|
||||
The context is a separate section of code that usually contains definitions and declarations, such as structs, externs, function declarations, and things of that nature.
|
||||
The context is passed to the compiler along with the code, so it's a good way to organize a scratch's functional code from its definitions and macros.
|
||||
</p>
|
||||
<h2 className={subtitle}>What's the context for?</h2>
|
||||
<p className="my-4">
|
||||
The context is a separate section of code that usually contains
|
||||
definitions and declarations, such as structs, externs, function
|
||||
declarations, and things of that nature. The context is passed
|
||||
to the compiler along with the code, so it's a good way to
|
||||
organize a scratch's functional code from its definitions and
|
||||
macros.
|
||||
</p>
|
||||
|
||||
<p className="my-4">
|
||||
Context is also given to the decompiler to assist with typing information and more accurate decompilation.
|
||||
</p>
|
||||
<p className="my-4">
|
||||
Context is also given to the decompiler to assist with typing
|
||||
information and more accurate decompilation.
|
||||
</p>
|
||||
|
||||
<h2 className={subtitle}>How does decomp.me work?</h2>
|
||||
<p className="my-4">
|
||||
The code from your scratch is submitted to the decomp.me server where it is compiled, run through objdump, and then compared against the target assembly.
|
||||
As you modify your code in the editor, the changes will be sent to the backend and compiled, so you'll see the results of your change in near real-time.
|
||||
The similarity between the compiled code's assembly and the target assembly is represented by a score, which is displayed in the editor.
|
||||
</p>
|
||||
<h2 className={subtitle}>How does decomp.me work?</h2>
|
||||
<p className="my-4">
|
||||
The code from your scratch is submitted to the decomp.me server
|
||||
where it is compiled, run through objdump, and then compared
|
||||
against the target assembly. As you modify your code in the
|
||||
editor, the changes will be sent to the backend and compiled, so
|
||||
you'll see the results of your change in near real-time. The
|
||||
similarity between the compiled code's assembly and the target
|
||||
assembly is represented by a score, which is displayed in the
|
||||
editor.
|
||||
</p>
|
||||
|
||||
<p className="my-4">
|
||||
The score is calculated by comparing the assembly instructions in the compiled code to the target assembly, and a penalty of different size is applied based on the kind of difference present among assembly instructions.
|
||||
The lower the score, the better!
|
||||
</p>
|
||||
<p className="my-4">
|
||||
The score is calculated by comparing the assembly instructions
|
||||
in the compiled code to the target assembly, and a penalty of
|
||||
different size is applied based on the kind of difference
|
||||
present among assembly instructions. The lower the score, the
|
||||
better!
|
||||
</p>
|
||||
|
||||
<h2 className={subtitle}>Where do I start?</h2>
|
||||
<p className="my-4">
|
||||
Currently, this website is meant to be used as a supplementary tool along with an existing decompilation project.
|
||||
Eventually, we hope to make the website a little more friendly to complete newcomers who aren't involved with any specific project.
|
||||
In the meantime, feel free to explore recent scratches and get a feel for how matching decomp works!
|
||||
</p>
|
||||
<h2 className={subtitle}>Where do I start?</h2>
|
||||
<p className="my-4">
|
||||
Currently, this website is meant to be used as a supplementary
|
||||
tool along with an existing decompilation project. Eventually,
|
||||
we hope to make the website a little more friendly to complete
|
||||
newcomers who aren't involved with any specific project. In the
|
||||
meantime, feel free to explore recent scratches and get a feel
|
||||
for how matching decomp works!
|
||||
</p>
|
||||
|
||||
<h2 className={subtitle}>Someone sent me a scratch. What do I do?</h2>
|
||||
<p className="my-4">
|
||||
Any scratch on the site can be played with. If you save a scratch that you don't own, your scratch will become a "fork" of the original.
|
||||
If you match the scratch, the original scratch will display a banner to notify visitors that the code is matched.
|
||||
</p>
|
||||
<p className="my-4">
|
||||
If you want to start your own scratch, you will need the assembly code for the function you are targeting in GNU assembly format.
|
||||
</p>
|
||||
<h2 className={subtitle}>
|
||||
Someone sent me a scratch. What do I do?
|
||||
</h2>
|
||||
<p className="my-4">
|
||||
Any scratch on the site can be played with. If you save a
|
||||
scratch that you don't own, your scratch will become a "fork" of
|
||||
the original. If you match the scratch, the original scratch
|
||||
will display a banner to notify visitors that the code is
|
||||
matched.
|
||||
</p>
|
||||
<p className="my-4">
|
||||
If you want to start your own scratch, you will need the
|
||||
assembly code for the function you are targeting in GNU assembly
|
||||
format.
|
||||
</p>
|
||||
|
||||
<h2 className={subtitle}>Can you help me match a scratch?</h2>
|
||||
<p className="my-4">
|
||||
You are welcome to ask for help in the #general-decompilation channel of our <DiscordLink/>.
|
||||
</p>
|
||||
<h2 className={subtitle}>Can you help me match a scratch?</h2>
|
||||
<p className="my-4">
|
||||
You are welcome to ask for help in the #general-decompilation
|
||||
channel of our <DiscordLink />.
|
||||
</p>
|
||||
|
||||
<h2 className={subtitle}>Can you add a preset for a game I'm working on?</h2>
|
||||
<p className="my-4">
|
||||
Absolutely we can, either raise a <FaqLink href="https://github.com/decompme/decomp.me/issues">GitHub Issue</FaqLink> or drop us a message in our <DiscordLink/>.
|
||||
</p>
|
||||
<h2 className={subtitle}>
|
||||
Can you add a preset for a game I'm working on?
|
||||
</h2>
|
||||
<p className="my-4">
|
||||
Absolutely we can, either raise a{" "}
|
||||
<FaqLink href="https://github.com/decompme/decomp.me/issues">
|
||||
GitHub Issue
|
||||
</FaqLink>{" "}
|
||||
or drop us a message in our <DiscordLink />.
|
||||
</p>
|
||||
|
||||
<h2 className={subtitle}>Can you add a compiler for a game I'm working on?</h2>
|
||||
<p className="my-4">
|
||||
This is something that you are able to do yourself.
|
||||
The compilers used by decomp.me can be found in our <FaqLink href="https://github.com/decompme/compilers">compilers repository</FaqLink>.
|
||||
Once the compiler has been added to that repo, it is very simple to add it to decomp.me, see <FaqLink href="https://github.com/decompme/decomp.me/pull/910">this PR</FaqLink> for an example.
|
||||
</p>
|
||||
<h2 className={subtitle}>
|
||||
Can you add a compiler for a game I'm working on?
|
||||
</h2>
|
||||
<p className="my-4">
|
||||
This is something that you are able to do yourself. The
|
||||
compilers used by decomp.me can be found in our{" "}
|
||||
<FaqLink href="https://github.com/decompme/compilers">
|
||||
compilers repository
|
||||
</FaqLink>
|
||||
. Once the compiler has been added to that repo, it is very
|
||||
simple to add it to decomp.me, see{" "}
|
||||
<FaqLink href="https://github.com/decompme/decomp.me/pull/910">
|
||||
this PR
|
||||
</FaqLink>{" "}
|
||||
for an example.
|
||||
</p>
|
||||
|
||||
<h2 className={subtitle}>Can you add "X" platform, e.g. PlayStation 3?</h2>
|
||||
<p className="my-4">
|
||||
The platforms that decomp.me supports are driven by the support for those platforms in the underlying tools that make up decomp.me.
|
||||
If these tools support the architecture for the new platform, and you have the compiler available, it is a straightforward process to add it to decomp.me.
|
||||
</p>
|
||||
<h2 className={subtitle}>
|
||||
Can you add "X" platform, e.g. PlayStation 3?
|
||||
</h2>
|
||||
<p className="my-4">
|
||||
The platforms that decomp.me supports are driven by the support
|
||||
for those platforms in the underlying tools that make up
|
||||
decomp.me. If these tools support the architecture for the new
|
||||
platform, and you have the compiler available, it is a
|
||||
straightforward process to add it to decomp.me.
|
||||
</p>
|
||||
|
||||
<h2 className={subtitle}>How do I report a bug?</h2>
|
||||
<p className="my-4">
|
||||
If you come across a bug, please reach out to us on our <DiscordLink/> and/or raise a <FaqLink href="https://github.com/decompme/decomp.me/issues">GitHub Issue</FaqLink> containing the steps necessary to replicate the bug.
|
||||
We will gladly accept bug-squashing PRs if you are able to fix the issue yourself!
|
||||
</p>
|
||||
<h2 className={subtitle}>How do I report a bug?</h2>
|
||||
<p className="my-4">
|
||||
If you come across a bug, please reach out to us on our{" "}
|
||||
<DiscordLink /> and/or raise a{" "}
|
||||
<FaqLink href="https://github.com/decompme/decomp.me/issues">
|
||||
GitHub Issue
|
||||
</FaqLink>{" "}
|
||||
containing the steps necessary to replicate the bug. We will
|
||||
gladly accept bug-squashing PRs if you are able to fix the issue
|
||||
yourself!
|
||||
</p>
|
||||
|
||||
<h2 className={subtitle}>Why frog?</h2>
|
||||
<p className="my-4">
|
||||
<Frog className="size-7" aria-label="Purple frog" />
|
||||
</p>
|
||||
|
||||
</main>
|
||||
<h2 className={subtitle}>Why frog?</h2>
|
||||
<p className="my-4">
|
||||
<Frog className="size-7" aria-label="Purple frog" />
|
||||
</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
import ErrorBoundary from "@/components/ErrorBoundary"
|
||||
import Footer from "@/components/Footer"
|
||||
import Nav from "@/components/Nav"
|
||||
import ErrorBoundary from "@/components/ErrorBoundary";
|
||||
import Footer from "@/components/Footer";
|
||||
import Nav from "@/components/Nav";
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <>
|
||||
<ErrorBoundary>
|
||||
<Nav />
|
||||
</ErrorBoundary>
|
||||
{children}
|
||||
<ErrorBoundary>
|
||||
<Footer />
|
||||
</ErrorBoundary>
|
||||
</>
|
||||
return (
|
||||
<>
|
||||
<ErrorBoundary>
|
||||
<Nav />
|
||||
</ErrorBoundary>
|
||||
{children}
|
||||
<ErrorBoundary>
|
||||
<Footer />
|
||||
</ErrorBoundary>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,82 +1,105 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, Suspense } from "react"
|
||||
import { useState, useEffect, Suspense } from "react";
|
||||
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
|
||||
import { useSWRConfig } from "swr"
|
||||
import { useSWRConfig } from "swr";
|
||||
|
||||
import GitHubLoginButton from "@/components/GitHubLoginButton"
|
||||
import LoadingSpinner from "@/components/loading.svg"
|
||||
import * as api from "@/lib/api"
|
||||
import { requestMissingScopes } from "@/lib/oauth"
|
||||
import GitHubLoginButton from "@/components/GitHubLoginButton";
|
||||
import LoadingSpinner from "@/components/loading.svg";
|
||||
import * as api from "@/lib/api";
|
||||
import { requestMissingScopes } from "@/lib/oauth";
|
||||
|
||||
function Login() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const [error, setError] = useState(null)
|
||||
const { mutate } = useSWRConfig()
|
||||
const code = searchParams.get("code")
|
||||
const next = searchParams.get("next")
|
||||
const githubError = searchParams.get("error")
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [error, setError] = useState(null);
|
||||
const { mutate } = useSWRConfig();
|
||||
const code = searchParams.get("code");
|
||||
const next = searchParams.get("next");
|
||||
const githubError = searchParams.get("error");
|
||||
|
||||
useEffect(() => {
|
||||
if (code && !error) {
|
||||
requestMissingScopes(() => api.post("/user", { code })).then((user: api.User) => {
|
||||
if (user.is_anonymous) {
|
||||
return Promise.reject(new Error("Still not logged-in."))
|
||||
}
|
||||
requestMissingScopes(() => api.post("/user", { code }))
|
||||
.then((user: api.User) => {
|
||||
if (user.is_anonymous) {
|
||||
return Promise.reject(
|
||||
new Error("Still not logged-in."),
|
||||
);
|
||||
}
|
||||
|
||||
mutate("/user", user)
|
||||
mutate("/user", user);
|
||||
|
||||
if (next) {
|
||||
router.replace(next)
|
||||
} else if (window.opener) {
|
||||
window.postMessage({
|
||||
source: "decomp_me_login",
|
||||
user,
|
||||
}, window.opener)
|
||||
window.close()
|
||||
} else {
|
||||
window.location.href = "/"
|
||||
}
|
||||
}).catch(error => {
|
||||
console.error(error)
|
||||
setError(error)
|
||||
})
|
||||
if (next) {
|
||||
router.replace(next);
|
||||
} else if (window.opener) {
|
||||
window.postMessage(
|
||||
{
|
||||
source: "decomp_me_login",
|
||||
user,
|
||||
},
|
||||
window.opener,
|
||||
);
|
||||
window.close();
|
||||
} else {
|
||||
window.location.href = "/";
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
setError(error);
|
||||
});
|
||||
}
|
||||
|
||||
if (githubError === "access_denied") {
|
||||
setError(new Error("Please grant access to your GitHub account to sign in!"))
|
||||
setError(
|
||||
new Error(
|
||||
"Please grant access to your GitHub account to sign in!",
|
||||
),
|
||||
);
|
||||
}
|
||||
}, [code, router, mutate, next, error, githubError])
|
||||
}, [code, router, mutate, next, error, githubError]);
|
||||
|
||||
return <main className="mx-auto flex max-w-prose items-center justify-center px-4 py-6 text-base leading-normal">
|
||||
{error ? <div>
|
||||
<h1 className="font-semibold text-3xl">Error signing in</h1>
|
||||
<p className="py-4">
|
||||
The following error prevented you from signing in:
|
||||
</p>
|
||||
<div className="rounded bg-gray-9 p-4 text-gray-2">
|
||||
<code className="font-mono text-sm">{error.toString()}</code>
|
||||
</div>
|
||||
<p className="py-4">
|
||||
You can try again by clicking the button below.
|
||||
</p>
|
||||
<GitHubLoginButton />
|
||||
</div> : code ? <div className="flex items-center justify-center gap-4 py-8 font-medium text-2xl text-gray-12">
|
||||
<LoadingSpinner width={32} className="animate-spin" />
|
||||
Signing in...
|
||||
</div> : <div>
|
||||
<p>
|
||||
Sign in to decomp.me
|
||||
</p>
|
||||
<GitHubLoginButton />
|
||||
</div>}
|
||||
</main>
|
||||
return (
|
||||
<main className="mx-auto flex max-w-prose items-center justify-center px-4 py-6 text-base leading-normal">
|
||||
{error ? (
|
||||
<div>
|
||||
<h1 className="font-semibold text-3xl">Error signing in</h1>
|
||||
<p className="py-4">
|
||||
The following error prevented you from signing in:
|
||||
</p>
|
||||
<div className="rounded bg-gray-9 p-4 text-gray-2">
|
||||
<code className="font-mono text-sm">
|
||||
{error.toString()}
|
||||
</code>
|
||||
</div>
|
||||
<p className="py-4">
|
||||
You can try again by clicking the button below.
|
||||
</p>
|
||||
<GitHubLoginButton />
|
||||
</div>
|
||||
) : code ? (
|
||||
<div className="flex items-center justify-center gap-4 py-8 font-medium text-2xl text-gray-12">
|
||||
<LoadingSpinner width={32} className="animate-spin" />
|
||||
Signing in...
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<p>Sign in to decomp.me</p>
|
||||
<GitHubLoginButton />
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
// Handles GitHub OAuth callback
|
||||
export default function Page() {
|
||||
return <Suspense><Login /></Suspense>
|
||||
return (
|
||||
<Suspense>
|
||||
<Login />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,201 +1,235 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useMemo, useReducer } from "react"
|
||||
import { useEffect, useState, useMemo, useReducer } from "react";
|
||||
|
||||
import Link from "next/link"
|
||||
import { useRouter } from "next/navigation"
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import AsyncButton from "@/components/AsyncButton"
|
||||
import { useCompilersForPlatform } from "@/components/compiler/compilers"
|
||||
import PresetSelect from "@/components/compiler/PresetSelect"
|
||||
import CodeMirror from "@/components/Editor/CodeMirror"
|
||||
import PlatformSelect from "@/components/PlatformSelect"
|
||||
import Select from "@/components/Select2"
|
||||
import * as api from "@/lib/api"
|
||||
import type { Library } from "@/lib/api/types"
|
||||
import { scratchUrl } from "@/lib/api/urls"
|
||||
import basicSetup from "@/lib/codemirror/basic-setup"
|
||||
import { cpp } from "@/lib/codemirror/cpp"
|
||||
import getTranslation from "@/lib/i18n/translate"
|
||||
import AsyncButton from "@/components/AsyncButton";
|
||||
import { useCompilersForPlatform } from "@/components/compiler/compilers";
|
||||
import PresetSelect from "@/components/compiler/PresetSelect";
|
||||
import CodeMirror from "@/components/Editor/CodeMirror";
|
||||
import PlatformSelect from "@/components/PlatformSelect";
|
||||
import Select from "@/components/Select2";
|
||||
import * as api from "@/lib/api";
|
||||
import type { Library } from "@/lib/api/types";
|
||||
import { scratchUrl } from "@/lib/api/urls";
|
||||
import basicSetup from "@/lib/codemirror/basic-setup";
|
||||
import { cpp } from "@/lib/codemirror/cpp";
|
||||
import getTranslation from "@/lib/i18n/translate";
|
||||
|
||||
import styles from "./new.module.scss"
|
||||
import styles from "./new.module.scss";
|
||||
|
||||
function getLabels(asm: string): string[] {
|
||||
const lines = asm.split("\n")
|
||||
let labels = []
|
||||
const lines = asm.split("\n");
|
||||
let labels = [];
|
||||
|
||||
const jtbl_label_regex = /(^L[0-9a-fA-F]{8}$)|(^jtbl_)/
|
||||
const jtbl_label_regex = /(^L[0-9a-fA-F]{8}$)|(^jtbl_)/;
|
||||
|
||||
for (const line of lines) {
|
||||
let match = line.match(/^\s*glabel\s+([A-z0-9_]+)\s*/)
|
||||
let match = line.match(/^\s*glabel\s+([A-z0-9_]+)\s*/);
|
||||
if (match) {
|
||||
labels.push(match[1])
|
||||
continue
|
||||
labels.push(match[1]);
|
||||
continue;
|
||||
}
|
||||
match = line.match(/^\s*\.global\s+([A-z0-9_]+)\s*/)
|
||||
match = line.match(/^\s*\.global\s+([A-z0-9_]+)\s*/);
|
||||
if (match) {
|
||||
labels.push(match[1])
|
||||
continue
|
||||
labels.push(match[1]);
|
||||
continue;
|
||||
}
|
||||
match = line.match(/^[A-z_]+_func_start\s+([A-z0-9_]+)/)
|
||||
match = line.match(/^[A-z_]+_func_start\s+([A-z0-9_]+)/);
|
||||
if (match) {
|
||||
labels.push(match[1])
|
||||
labels.push(match[1]);
|
||||
}
|
||||
}
|
||||
|
||||
labels = labels.filter(label => !jtbl_label_regex.test(label))
|
||||
labels = labels.filter((label) => !jtbl_label_regex.test(label));
|
||||
|
||||
return labels
|
||||
return labels;
|
||||
}
|
||||
|
||||
export default function NewScratchForm({ serverCompilers }: {
|
||||
export default function NewScratchForm({
|
||||
serverCompilers,
|
||||
}: {
|
||||
serverCompilers: {
|
||||
platforms: {
|
||||
[id: string]: api.Platform
|
||||
}
|
||||
[id: string]: api.Platform;
|
||||
};
|
||||
compilers: {
|
||||
[id: string]: api.Compiler
|
||||
}
|
||||
}
|
||||
[id: string]: api.Compiler;
|
||||
};
|
||||
};
|
||||
}) {
|
||||
const [asm, setAsm] = useState("")
|
||||
const [context, setContext] = useState("")
|
||||
const [platform, setPlatform] = useState("")
|
||||
const [compilerId, setCompilerId] = useState<string>()
|
||||
const [compilerFlags, setCompilerFlags] = useState<string>("")
|
||||
const [diffFlags, setDiffFlags] = useState<string[]>([])
|
||||
const [libraries, setLibraries] = useState<Library[]>([])
|
||||
const [presetId, setPresetId] = useState<number | undefined>()
|
||||
const [asm, setAsm] = useState("");
|
||||
const [context, setContext] = useState("");
|
||||
const [platform, setPlatform] = useState("");
|
||||
const [compilerId, setCompilerId] = useState<string>();
|
||||
const [compilerFlags, setCompilerFlags] = useState<string>("");
|
||||
const [diffFlags, setDiffFlags] = useState<string[]>([]);
|
||||
const [libraries, setLibraries] = useState<Library[]>([]);
|
||||
const [presetId, setPresetId] = useState<number | undefined>();
|
||||
|
||||
const [ready, setReady] = useState(false)
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
const [valueVersion, incrementValueVersion] = useReducer(x => x + 1, 0)
|
||||
const [valueVersion, incrementValueVersion] = useReducer((x) => x + 1, 0);
|
||||
|
||||
const router = useRouter()
|
||||
const router = useRouter();
|
||||
|
||||
const defaultLabel = useMemo(() => {
|
||||
const labels = getLabels(asm)
|
||||
return labels.length > 0 ? labels[0] : null
|
||||
}, [asm])
|
||||
const [label, setLabel] = useState<string>("")
|
||||
const labels = getLabels(asm);
|
||||
return labels.length > 0 ? labels[0] : null;
|
||||
}, [asm]);
|
||||
const [label, setLabel] = useState<string>("");
|
||||
|
||||
const setPreset = (preset: api.Preset) => {
|
||||
if (preset) {
|
||||
setPresetId(preset.id)
|
||||
setPlatform(preset.platform)
|
||||
setCompilerId(preset.compiler)
|
||||
setCompilerFlags(preset.compiler_flags)
|
||||
setDiffFlags(preset.diff_flags)
|
||||
setLibraries(preset.libraries)
|
||||
setPresetId(preset.id);
|
||||
setPlatform(preset.platform);
|
||||
setCompilerId(preset.compiler);
|
||||
setCompilerFlags(preset.compiler_flags);
|
||||
setDiffFlags(preset.diff_flags);
|
||||
setLibraries(preset.libraries);
|
||||
} else {
|
||||
// User selected "Custom", don't change platform or compiler
|
||||
setPresetId(undefined)
|
||||
setCompilerFlags("")
|
||||
setDiffFlags([])
|
||||
setLibraries([])
|
||||
setPresetId(undefined);
|
||||
setCompilerFlags("");
|
||||
setDiffFlags([]);
|
||||
setLibraries([]);
|
||||
}
|
||||
}
|
||||
};
|
||||
const setCompiler = (compiler?: string) => {
|
||||
setCompilerId(compiler)
|
||||
setCompilerFlags("")
|
||||
setDiffFlags([])
|
||||
setLibraries([])
|
||||
setPresetId(undefined)
|
||||
}
|
||||
setCompilerId(compiler);
|
||||
setCompilerFlags("");
|
||||
setDiffFlags([]);
|
||||
setLibraries([]);
|
||||
setPresetId(undefined);
|
||||
};
|
||||
|
||||
const presets = useMemo(() => {
|
||||
const dict: Record<string, any> = {}
|
||||
const dict: Record<string, any> = {};
|
||||
for (const v of Object.values(serverCompilers.platforms)) {
|
||||
for (const p of v.presets) {
|
||||
dict[p.id] = p
|
||||
dict[p.id] = p;
|
||||
}
|
||||
}
|
||||
return dict
|
||||
}, [serverCompilers])
|
||||
return dict;
|
||||
}, [serverCompilers]);
|
||||
|
||||
// Load fields from localStorage
|
||||
useEffect(() => {
|
||||
try {
|
||||
setLabel(localStorage.new_scratch_label ?? "")
|
||||
setAsm(localStorage.new_scratch_asm ?? "")
|
||||
setContext(localStorage.new_scratch_context ?? "")
|
||||
const pid = Number.parseInt(localStorage.new_scratch_presetId)
|
||||
setLabel(localStorage.new_scratch_label ?? "");
|
||||
setAsm(localStorage.new_scratch_asm ?? "");
|
||||
setContext(localStorage.new_scratch_context ?? "");
|
||||
const pid = Number.parseInt(localStorage.new_scratch_presetId);
|
||||
if (!Number.isNaN(pid)) {
|
||||
const preset = presets[pid]
|
||||
const preset = presets[pid];
|
||||
if (preset) {
|
||||
setPreset(preset)
|
||||
setPreset(preset);
|
||||
}
|
||||
} else {
|
||||
setPlatform(localStorage.new_scratch_platform ?? "")
|
||||
setCompilerId(localStorage.new_scratch_compilerId ?? undefined)
|
||||
setCompilerFlags(localStorage.new_scratch_compilerFlags ?? "")
|
||||
setDiffFlags(JSON.parse(localStorage.new_scratch_diffFlags ?? "[]"))
|
||||
setLibraries(JSON.parse(localStorage.new_scratch_libraries ?? "[]"))
|
||||
setPlatform(localStorage.new_scratch_platform ?? "");
|
||||
setCompilerId(localStorage.new_scratch_compilerId ?? undefined);
|
||||
setCompilerFlags(localStorage.new_scratch_compilerFlags ?? "");
|
||||
setDiffFlags(
|
||||
JSON.parse(localStorage.new_scratch_diffFlags ?? "[]"),
|
||||
);
|
||||
setLibraries(
|
||||
JSON.parse(localStorage.new_scratch_libraries ?? "[]"),
|
||||
);
|
||||
}
|
||||
incrementValueVersion()
|
||||
incrementValueVersion();
|
||||
} catch (error) {
|
||||
console.warn("bad localStorage", error)
|
||||
console.warn("bad localStorage", error);
|
||||
}
|
||||
setReady(true)
|
||||
}, [presets])
|
||||
setReady(true);
|
||||
}, [presets]);
|
||||
|
||||
// Update localStorage
|
||||
useEffect(() => {
|
||||
if (!ready)
|
||||
return
|
||||
if (!ready) return;
|
||||
|
||||
localStorage.new_scratch_label = label
|
||||
localStorage.new_scratch_asm = asm
|
||||
localStorage.new_scratch_context = context
|
||||
localStorage.new_scratch_platform = platform
|
||||
localStorage.new_scratch_compilerId = compilerId
|
||||
localStorage.new_scratch_compilerFlags = compilerFlags
|
||||
localStorage.new_scratch_diffFlags = JSON.stringify(diffFlags)
|
||||
localStorage.new_scratch_libraries = JSON.stringify(libraries)
|
||||
localStorage.new_scratch_label = label;
|
||||
localStorage.new_scratch_asm = asm;
|
||||
localStorage.new_scratch_context = context;
|
||||
localStorage.new_scratch_platform = platform;
|
||||
localStorage.new_scratch_compilerId = compilerId;
|
||||
localStorage.new_scratch_compilerFlags = compilerFlags;
|
||||
localStorage.new_scratch_diffFlags = JSON.stringify(diffFlags);
|
||||
localStorage.new_scratch_libraries = JSON.stringify(libraries);
|
||||
if (presetId === undefined) {
|
||||
localStorage.removeItem("new_scratch_presetId")
|
||||
localStorage.removeItem("new_scratch_presetId");
|
||||
} else {
|
||||
localStorage.new_scratch_presetId = presetId
|
||||
localStorage.new_scratch_presetId = presetId;
|
||||
}
|
||||
}, [ready, label, asm, context, platform, compilerId, compilerFlags, diffFlags, libraries, presetId])
|
||||
}, [
|
||||
ready,
|
||||
label,
|
||||
asm,
|
||||
context,
|
||||
platform,
|
||||
compilerId,
|
||||
compilerFlags,
|
||||
diffFlags,
|
||||
libraries,
|
||||
presetId,
|
||||
]);
|
||||
|
||||
// Use first available platform if no platform was selected or is unavailable
|
||||
if (!platform || Object.keys(serverCompilers.platforms).indexOf(platform) === -1) {
|
||||
setPlatform(Object.keys(serverCompilers.platforms)[0])
|
||||
if (
|
||||
!platform ||
|
||||
Object.keys(serverCompilers.platforms).indexOf(platform) === -1
|
||||
) {
|
||||
setPlatform(Object.keys(serverCompilers.platforms)[0]);
|
||||
}
|
||||
|
||||
const platformCompilers = useCompilersForPlatform(platform, serverCompilers.compilers)
|
||||
const platformCompilers = useCompilersForPlatform(
|
||||
platform,
|
||||
serverCompilers.compilers,
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!ready)
|
||||
return
|
||||
if (!ready) return;
|
||||
|
||||
if (presetId !== undefined || compilerId !== undefined) {
|
||||
// User has specified a preset or compiler, don't override it
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
if (Object.keys(platformCompilers).length === 0) {
|
||||
console.warn("This platform has no supported compilers", platform)
|
||||
console.warn("This platform has no supported compilers", platform);
|
||||
} else {
|
||||
// Fall back to the first supported compiler and no flags...
|
||||
setCompiler(Object.keys(platformCompilers)[0])
|
||||
setCompiler(Object.keys(platformCompilers)[0]);
|
||||
// However, if there is a preset for this platform, use it
|
||||
for (const v of Object.values(serverCompilers.compilers)) {
|
||||
if (v.platform === platform && serverCompilers.platforms[platform].presets.length > 0) {
|
||||
setPreset(serverCompilers.platforms[platform].presets[0])
|
||||
break
|
||||
if (
|
||||
v.platform === platform &&
|
||||
serverCompilers.platforms[platform].presets.length > 0
|
||||
) {
|
||||
setPreset(serverCompilers.platforms[platform].presets[0]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [ready, presetId, compilerId, platformCompilers, serverCompilers, platform])
|
||||
}, [
|
||||
ready,
|
||||
presetId,
|
||||
compilerId,
|
||||
platformCompilers,
|
||||
serverCompilers,
|
||||
platform,
|
||||
]);
|
||||
|
||||
const compilersTranslation = getTranslation("compilers")
|
||||
const compilersTranslation = getTranslation("compilers");
|
||||
const compilerChoiceOptions = useMemo(() => {
|
||||
return Object.keys(platformCompilers).reduce((sum, id) => {
|
||||
sum[id] = compilersTranslation.t(id)
|
||||
return sum
|
||||
}, {} as Record<string, string>)
|
||||
}, [platformCompilers, compilersTranslation])
|
||||
return Object.keys(platformCompilers).reduce(
|
||||
(sum, id) => {
|
||||
sum[id] = compilersTranslation.t(id);
|
||||
return sum;
|
||||
},
|
||||
{} as Record<string, string>,
|
||||
);
|
||||
}, [platformCompilers, compilersTranslation]);
|
||||
|
||||
const submit = async () => {
|
||||
try {
|
||||
@@ -209,116 +243,133 @@ export default function NewScratchForm({ serverCompilers }: {
|
||||
libraries: libraries,
|
||||
preset: presetId,
|
||||
diff_label: label || defaultLabel || "",
|
||||
})
|
||||
});
|
||||
|
||||
localStorage.new_scratch_label = ""
|
||||
localStorage.new_scratch_asm = ""
|
||||
localStorage.new_scratch_label = "";
|
||||
localStorage.new_scratch_asm = "";
|
||||
|
||||
await api.claimScratch(scratch)
|
||||
await api.claimScratch(scratch);
|
||||
|
||||
router.push(scratchUrl(scratch))
|
||||
router.push(scratchUrl(scratch));
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
throw error
|
||||
console.error(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return <div>
|
||||
return (
|
||||
<div>
|
||||
<p className={styles.label}>
|
||||
Platform
|
||||
</p>
|
||||
<PlatformSelect
|
||||
platforms={serverCompilers.platforms}
|
||||
value={platform}
|
||||
onChange={p => {
|
||||
setPlatform(p)
|
||||
setCompiler()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className={styles.label}>Platform</p>
|
||||
<PlatformSelect
|
||||
platforms={serverCompilers.platforms}
|
||||
value={platform}
|
||||
onChange={(p) => {
|
||||
setPlatform(p);
|
||||
setCompiler();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className={styles.label}>
|
||||
Compiler
|
||||
</p>
|
||||
<div className={styles.compilerContainer}>
|
||||
<div>
|
||||
<span className={styles.compilerChoiceHeading}>Select a compiler</span>
|
||||
<Select
|
||||
className={styles.compilerChoiceSelect}
|
||||
options={compilerChoiceOptions}
|
||||
value={compilerId}
|
||||
onChange={setCompiler}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.compilerChoiceOr}>or</div>
|
||||
<div>
|
||||
<span className={styles.compilerChoiceHeading}>Select a preset</span>
|
||||
<PresetSelect
|
||||
className={styles.compilerChoiceSelect}
|
||||
platform={platform}
|
||||
presetId={presetId}
|
||||
setPreset={setPreset}
|
||||
serverPresets={platform && serverCompilers.platforms[platform].presets}
|
||||
/>
|
||||
<div>
|
||||
<p className={styles.label}>Compiler</p>
|
||||
<div className={styles.compilerContainer}>
|
||||
<div>
|
||||
<span className={styles.compilerChoiceHeading}>
|
||||
Select a compiler
|
||||
</span>
|
||||
<Select
|
||||
className={styles.compilerChoiceSelect}
|
||||
options={compilerChoiceOptions}
|
||||
value={compilerId}
|
||||
onChange={setCompiler}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.compilerChoiceOr}>or</div>
|
||||
<div>
|
||||
<span className={styles.compilerChoiceHeading}>
|
||||
Select a preset
|
||||
</span>
|
||||
<PresetSelect
|
||||
className={styles.compilerChoiceSelect}
|
||||
platform={platform}
|
||||
presetId={presetId}
|
||||
setPreset={setPreset}
|
||||
serverPresets={
|
||||
platform &&
|
||||
serverCompilers.platforms[platform].presets
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={styles.label} htmlFor="label">
|
||||
Diff label <small>(asm label from which the diff will begin)</small>
|
||||
</label>
|
||||
<input
|
||||
name="label"
|
||||
type="text"
|
||||
value={label}
|
||||
placeholder={defaultLabel}
|
||||
onChange={e => setLabel((e.target as HTMLInputElement).value)}
|
||||
className={styles.textInput}
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.editorContainer}>
|
||||
<p className={styles.label}>Target assembly <small>(required)</small></p>
|
||||
<CodeMirror
|
||||
className={styles.editor}
|
||||
value={asm}
|
||||
valueVersion={valueVersion}
|
||||
onChange={setAsm}
|
||||
extensions={basicSetup}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.editorContainer}>
|
||||
<p className={styles.label}>
|
||||
Context <small>(any typedefs, structs, and declarations you would like to include go here; typically generated with m2ctx.py)</small>
|
||||
</p>
|
||||
<CodeMirror
|
||||
className={styles.editor}
|
||||
value={context}
|
||||
valueVersion={valueVersion}
|
||||
onChange={setContext}
|
||||
extensions={[basicSetup, cpp()]}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={styles.label} htmlFor="label">
|
||||
Diff label{" "}
|
||||
<small>(asm label from which the diff will begin)</small>
|
||||
</label>
|
||||
<input
|
||||
name="label"
|
||||
type="text"
|
||||
value={label}
|
||||
placeholder={defaultLabel}
|
||||
onChange={(e) =>
|
||||
setLabel((e.target as HTMLInputElement).value)
|
||||
}
|
||||
className={styles.textInput}
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.editorContainer}>
|
||||
<p className={styles.label}>
|
||||
Target assembly <small>(required)</small>
|
||||
</p>
|
||||
<CodeMirror
|
||||
className={styles.editor}
|
||||
value={asm}
|
||||
valueVersion={valueVersion}
|
||||
onChange={setAsm}
|
||||
extensions={basicSetup}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.editorContainer}>
|
||||
<p className={styles.label}>
|
||||
Context{" "}
|
||||
<small>
|
||||
(any typedefs, structs, and declarations you would like
|
||||
to include go here; typically generated with m2ctx.py)
|
||||
</small>
|
||||
</p>
|
||||
<CodeMirror
|
||||
className={styles.editor}
|
||||
value={context}
|
||||
valueVersion={valueVersion}
|
||||
onChange={setContext}
|
||||
extensions={[basicSetup, cpp()]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<AsyncButton
|
||||
primary
|
||||
disabled={asm.length === 0}
|
||||
onClick={submit}
|
||||
errorPlacement="right-center"
|
||||
className="mt-2"
|
||||
>
|
||||
Create scratch
|
||||
</AsyncButton>
|
||||
<p className={styles.privacyNotice}>
|
||||
decomp.me will store any data you submit and link it to your session.<br />
|
||||
For more information, see our <Link href="/privacy">privacy policy</Link>.
|
||||
</p>
|
||||
<div>
|
||||
<AsyncButton
|
||||
primary
|
||||
disabled={asm.length === 0}
|
||||
onClick={submit}
|
||||
errorPlacement="right-center"
|
||||
className="mt-2"
|
||||
>
|
||||
Create scratch
|
||||
</AsyncButton>
|
||||
<p className={styles.privacyNotice}>
|
||||
decomp.me will store any data you submit and link it to your
|
||||
session.
|
||||
<br />
|
||||
For more information, see our{" "}
|
||||
<Link href="/privacy">privacy policy</Link>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
const DESCRIPTION = "A scratch is a playground where you can work on matching a given target function using any compiler options you like."
|
||||
const DESCRIPTION =
|
||||
"A scratch is a playground where you can work on matching a given target function using any compiler options you like.";
|
||||
|
||||
export default DESCRIPTION
|
||||
export default DESCRIPTION;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { ReactNode } from "react"
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export default function Layout({ children }: { children: ReactNode }) {
|
||||
return <div className="mx-auto max-w-[46.5rem] p-4 pb-2 text-justify text-base leading-normal">
|
||||
{children}
|
||||
</div>
|
||||
return (
|
||||
<div className="mx-auto max-w-[46.5rem] p-4 pb-2 text-justify text-base leading-normal">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
import { get } from "@/lib/api/request"
|
||||
import { get } from "@/lib/api/request";
|
||||
|
||||
import DESCRIPTION from "./description"
|
||||
import NewScratchForm from "./NewScratchForm"
|
||||
import DESCRIPTION from "./description";
|
||||
import NewScratchForm from "./NewScratchForm";
|
||||
|
||||
export const metadata = {
|
||||
title: "New scratch",
|
||||
}
|
||||
};
|
||||
|
||||
export default async function NewScratchPage() {
|
||||
const compilers = await get("/compiler")
|
||||
const compilers = await get("/compiler");
|
||||
|
||||
return <main>
|
||||
<h1 className="font-semibold text-2xl text-gray-12 tracking-tight md:text-3xl">Start a new scratch</h1>
|
||||
<p className="max-w-prose py-3 leading-snug">{DESCRIPTION}</p>
|
||||
<NewScratchForm serverCompilers={compilers} />
|
||||
</main>
|
||||
return (
|
||||
<main>
|
||||
<h1 className="font-semibold text-2xl text-gray-12 tracking-tight md:text-3xl">
|
||||
Start a new scratch
|
||||
</h1>
|
||||
<p className="max-w-prose py-3 leading-snug">{DESCRIPTION}</p>
|
||||
<NewScratchForm serverCompilers={compilers} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import type { Metadata } from "next"
|
||||
import type { Metadata } from "next";
|
||||
|
||||
import ScratchList, { SingleLineScratchItem } from "@/components/ScratchList"
|
||||
import YourScratchList from "@/components/YourScratchList"
|
||||
import ScratchList, { SingleLineScratchItem } from "@/components/ScratchList";
|
||||
import YourScratchList from "@/components/YourScratchList";
|
||||
|
||||
import WelcomeInfo from "./WelcomeInfo"
|
||||
import WelcomeInfo from "./WelcomeInfo";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const title = "decomp.me"
|
||||
const title = "decomp.me";
|
||||
|
||||
const description = "A collaborative decompilation platform."
|
||||
const description = "A collaborative decompilation platform.";
|
||||
|
||||
return {
|
||||
openGraph: {
|
||||
@@ -24,25 +24,25 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
return <main>
|
||||
<header className="w-full py-16">
|
||||
<WelcomeInfo />
|
||||
</header>
|
||||
<div className="mx-auto flex w-full max-w-screen-xl flex-col gap-16 p-8 md:flex-row">
|
||||
<section className="md:w-1/2 lg:w-1/4">
|
||||
<h2 className="mb-2 text-lg">Your scratches</h2>
|
||||
<YourScratchList
|
||||
item={SingleLineScratchItem}
|
||||
/>
|
||||
</section>
|
||||
<section className="md:w-1/2 lg:w-3/4">
|
||||
<h2 className="mb-2 text-lg">Recent activity</h2>
|
||||
<ScratchList url="/scratch?page_size=20" />
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
return (
|
||||
<main>
|
||||
<header className="w-full py-16">
|
||||
<WelcomeInfo />
|
||||
</header>
|
||||
<div className="mx-auto flex w-full max-w-screen-xl flex-col gap-16 p-8 md:flex-row">
|
||||
<section className="md:w-1/2 lg:w-1/4">
|
||||
<h2 className="mb-2 text-lg">Your scratches</h2>
|
||||
<YourScratchList item={SingleLineScratchItem} />
|
||||
</section>
|
||||
<section className="md:w-1/2 lg:w-3/4">
|
||||
<h2 className="mb-2 text-lg">Recent activity</h2>
|
||||
<ScratchList url="/scratch?page_size=20" />
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,30 +1,35 @@
|
||||
import type { Metadata } from "next"
|
||||
import type { Metadata } from "next";
|
||||
|
||||
import { notFound } from "next/navigation"
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import { PlatformIcon } from "@/components/PlatformSelect/PlatformIcon"
|
||||
import ScratchList, { ScratchItemPlatformList } from "@/components/ScratchList"
|
||||
import { get } from "@/lib/api/request"
|
||||
import type { PlatformMetadata } from "@/lib/api/types"
|
||||
import { PlatformIcon } from "@/components/PlatformSelect/PlatformIcon";
|
||||
import ScratchList, { ScratchItemPlatformList } from "@/components/ScratchList";
|
||||
import { get } from "@/lib/api/request";
|
||||
import type { PlatformMetadata } from "@/lib/api/types";
|
||||
|
||||
export async function generateMetadata({ params }: { params: { id: number } }):Promise<Metadata> {
|
||||
let platform: PlatformMetadata
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: { params: { id: number } }): Promise<Metadata> {
|
||||
let platform: PlatformMetadata;
|
||||
|
||||
try {
|
||||
platform = await get(`/platform/${params.id}`)
|
||||
platform = await get(`/platform/${params.id}`);
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
if (!platform) {
|
||||
return notFound()
|
||||
return notFound();
|
||||
}
|
||||
|
||||
let description = "There "
|
||||
description += platform.num_scratches === 1 ? "is " : "are "
|
||||
description += platform.num_scratches === 0 ? "currently no " : `${platform.num_scratches.toLocaleString("en-US")} `
|
||||
description += platform.num_scratches === 1 ? "scratch " : "scratches "
|
||||
description += "for this platform."
|
||||
let description = "There ";
|
||||
description += platform.num_scratches === 1 ? "is " : "are ";
|
||||
description +=
|
||||
platform.num_scratches === 0
|
||||
? "currently no "
|
||||
: `${platform.num_scratches.toLocaleString("en-US")} `;
|
||||
description += platform.num_scratches === 1 ? "scratch " : "scratches ";
|
||||
description += "for this platform.";
|
||||
|
||||
return {
|
||||
title: platform.name,
|
||||
@@ -32,37 +37,37 @@ export async function generateMetadata({ params }: { params: { id: number } }):P
|
||||
title: platform.name,
|
||||
description: description,
|
||||
},
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default async function Page({ params }: { params: { id: number } }) {
|
||||
let platform: PlatformMetadata
|
||||
let platform: PlatformMetadata;
|
||||
try {
|
||||
platform = await get(`/platform/${params.id}`)
|
||||
platform = await get(`/platform/${params.id}`);
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
if (!platform) {
|
||||
return notFound()
|
||||
return notFound();
|
||||
}
|
||||
|
||||
return <main className="mx-auto w-full max-w-3xl p-4">
|
||||
<div className="flex items-center gap-2 font-medium text-2xl">
|
||||
<PlatformIcon platform={platform.id} size={32} />
|
||||
<h1>
|
||||
{platform.name}
|
||||
</h1>
|
||||
</div>
|
||||
<p className="py-3 text-gray-11">{platform.description}</p>
|
||||
return (
|
||||
<main className="mx-auto w-full max-w-3xl p-4">
|
||||
<div className="flex items-center gap-2 font-medium text-2xl">
|
||||
<PlatformIcon platform={platform.id} size={32} />
|
||||
<h1>{platform.name}</h1>
|
||||
</div>
|
||||
<p className="py-3 text-gray-11">{platform.description}</p>
|
||||
|
||||
<section>
|
||||
<ScratchList
|
||||
url={`/scratch?platform=${platform.id}&page_size=20`}
|
||||
item={ScratchItemPlatformList}
|
||||
isSortable={true}
|
||||
title={`Scratches (${platform.num_scratches})`}
|
||||
/>
|
||||
</section>
|
||||
</main>
|
||||
<section>
|
||||
<ScratchList
|
||||
url={`/scratch?platform=${platform.id}&page_size=20`}
|
||||
item={ScratchItemPlatformList}
|
||||
isSortable={true}
|
||||
title={`Scratches (${platform.num_scratches})`}
|
||||
/>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,31 +1,36 @@
|
||||
import type { Metadata } from "next"
|
||||
import type { Metadata } from "next";
|
||||
|
||||
import { notFound } from "next/navigation"
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import { PlatformIcon } from "@/components/PlatformSelect/PlatformIcon"
|
||||
import ScratchList, { ScratchItemPresetList } from "@/components/ScratchList"
|
||||
import { get } from "@/lib/api/request"
|
||||
import type { Preset } from "@/lib/api/types"
|
||||
import getTranslation from "@/lib/i18n/translate"
|
||||
import { PlatformIcon } from "@/components/PlatformSelect/PlatformIcon";
|
||||
import ScratchList, { ScratchItemPresetList } from "@/components/ScratchList";
|
||||
import { get } from "@/lib/api/request";
|
||||
import type { Preset } from "@/lib/api/types";
|
||||
import getTranslation from "@/lib/i18n/translate";
|
||||
|
||||
export async function generateMetadata({ params }: { params: { id: number } }): Promise<Metadata> {
|
||||
let preset: Preset
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: { params: { id: number } }): Promise<Metadata> {
|
||||
let preset: Preset;
|
||||
|
||||
try {
|
||||
preset = await get(`/preset/${params.id}`)
|
||||
preset = await get(`/preset/${params.id}`);
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
if (!preset) {
|
||||
return notFound()
|
||||
return notFound();
|
||||
}
|
||||
|
||||
let description = "There "
|
||||
description += preset.num_scratches === 1 ? "is " : "are "
|
||||
description += preset.num_scratches === 0 ? "currently no " : `${preset.num_scratches.toLocaleString("en-US")} `
|
||||
description += preset.num_scratches === 1 ? "scratch " : "scratches "
|
||||
description += "that use this preset."
|
||||
let description = "There ";
|
||||
description += preset.num_scratches === 1 ? "is " : "are ";
|
||||
description +=
|
||||
preset.num_scratches === 0
|
||||
? "currently no "
|
||||
: `${preset.num_scratches.toLocaleString("en-US")} `;
|
||||
description += preset.num_scratches === 1 ? "scratch " : "scratches ";
|
||||
description += "that use this preset.";
|
||||
|
||||
return {
|
||||
title: preset.name,
|
||||
@@ -33,41 +38,41 @@ export async function generateMetadata({ params }: { params: { id: number } }):
|
||||
title: preset.name,
|
||||
description: description,
|
||||
},
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default async function Page({ params }: { params: { id: number } }) {
|
||||
const compilersTranslation = getTranslation("compilers")
|
||||
const compilersTranslation = getTranslation("compilers");
|
||||
|
||||
let preset: Preset
|
||||
let preset: Preset;
|
||||
try {
|
||||
preset = await get(`/preset/${params.id}`)
|
||||
preset = await get(`/preset/${params.id}`);
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
if (!preset) {
|
||||
return notFound()
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const compilerName = compilersTranslation.t(preset.compiler)
|
||||
const compilerName = compilersTranslation.t(preset.compiler);
|
||||
|
||||
return <main className="mx-auto w-full max-w-3xl p-4">
|
||||
<div className="flex items-center gap-2 font-medium text-2xl">
|
||||
<PlatformIcon platform={preset.platform} size={32} />
|
||||
<h1>
|
||||
{preset.name}
|
||||
</h1>
|
||||
</div>
|
||||
<p className="py-3 text-gray-11">{compilerName}</p>
|
||||
return (
|
||||
<main className="mx-auto w-full max-w-3xl p-4">
|
||||
<div className="flex items-center gap-2 font-medium text-2xl">
|
||||
<PlatformIcon platform={preset.platform} size={32} />
|
||||
<h1>{preset.name}</h1>
|
||||
</div>
|
||||
<p className="py-3 text-gray-11">{compilerName}</p>
|
||||
|
||||
<section>
|
||||
<ScratchList
|
||||
url={`/scratch?preset=${preset.id}&page_size=20`}
|
||||
item={ScratchItemPresetList}
|
||||
isSortable={true}
|
||||
title={`Scratches (${preset.num_scratches})`}
|
||||
/>
|
||||
</section>
|
||||
</main>
|
||||
<section>
|
||||
<ScratchList
|
||||
url={`/scratch?preset=${preset.id}&page_size=20`}
|
||||
item={ScratchItemPresetList}
|
||||
isSortable={true}
|
||||
title={`Scratches (${preset.num_scratches})`}
|
||||
/>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Presets } from "@/app/(navfooter)/preset/presets"
|
||||
import { get } from "@/lib/api/request"
|
||||
import { Presets } from "@/app/(navfooter)/preset/presets";
|
||||
import { get } from "@/lib/api/request";
|
||||
|
||||
export default async function Page() {
|
||||
const compilers = await get("/compiler")
|
||||
const compilers = await get("/compiler");
|
||||
|
||||
return (
|
||||
<main className="mx-auto w-full max-w-3xl p-4">
|
||||
<Presets serverCompilers={compilers}/>
|
||||
<Presets serverCompilers={compilers} />
|
||||
</main>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,36 +1,41 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import { useState } from "react"
|
||||
import { useState } from "react";
|
||||
|
||||
import PlatformSelect from "@/components/PlatformSelect"
|
||||
import { PresetList } from "@/components/PresetList"
|
||||
import type * as api from "@/lib/api"
|
||||
import PlatformSelect from "@/components/PlatformSelect";
|
||||
import { PresetList } from "@/components/PresetList";
|
||||
import type * as api from "@/lib/api";
|
||||
|
||||
export function Presets({ serverCompilers }: {
|
||||
export function Presets({
|
||||
serverCompilers,
|
||||
}: {
|
||||
serverCompilers: {
|
||||
platforms: {
|
||||
[id: string]: api.Platform
|
||||
}
|
||||
[id: string]: api.Platform;
|
||||
};
|
||||
compilers: {
|
||||
[id: string]: api.Compiler
|
||||
}
|
||||
}
|
||||
[id: string]: api.Compiler;
|
||||
};
|
||||
};
|
||||
}) {
|
||||
const platforms = Object.keys(serverCompilers.platforms);
|
||||
|
||||
const platforms = Object.keys(serverCompilers.platforms)
|
||||
|
||||
const [platform, setPlatform] = useState<string>(platforms.length > 0 ? platforms[0] : "")
|
||||
const [platform, setPlatform] = useState<string>(
|
||||
platforms.length > 0 ? platforms[0] : "",
|
||||
);
|
||||
|
||||
return (
|
||||
<section>
|
||||
<h2 className="pb-2 font-medium text-lg tracking-tight">Platforms</h2>
|
||||
<h2 className="pb-2 font-medium text-lg tracking-tight">
|
||||
Platforms
|
||||
</h2>
|
||||
<PlatformSelect
|
||||
platforms={serverCompilers.platforms}
|
||||
value={platform}
|
||||
onChange={setPlatform}
|
||||
/>
|
||||
<h2 className="py-2 font-medium text-lg tracking-tight">Presets</h2>
|
||||
<PresetList url={`/preset?platform=${platform}`}/>
|
||||
<PresetList url={`/preset?platform=${platform}`} />
|
||||
</section>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,112 +1,159 @@
|
||||
import Link from "next/link"
|
||||
import Link from "next/link";
|
||||
|
||||
const subtitle = "mt-8 text-xl font-semibold tracking-tight text-gray-11"
|
||||
const link = "text-blue-11 hover:underline active:translate-y-px"
|
||||
const subtitle = "mt-8 text-xl font-semibold tracking-tight text-gray-11";
|
||||
const link = "text-blue-11 hover:underline active:translate-y-px";
|
||||
|
||||
export const metadata = {
|
||||
title: "Privacy policy",
|
||||
}
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
return <main className="mx-auto max-w-prose p-4 pb-2 text-justify text-base leading-normal">
|
||||
<h1 className="font-semibold text-2xl text-gray-12 tracking-tight md:text-3xl">
|
||||
Privacy policy
|
||||
</h1>
|
||||
return (
|
||||
<main className="mx-auto max-w-prose p-4 pb-2 text-justify text-base leading-normal">
|
||||
<h1 className="font-semibold text-2xl text-gray-12 tracking-tight md:text-3xl">
|
||||
Privacy policy
|
||||
</h1>
|
||||
|
||||
<p className="mt-2 mb-6 text-gray-11 text-sm">
|
||||
Last updated January 13th 2022
|
||||
</p>
|
||||
<p className="mt-2 mb-6 text-gray-11 text-sm">
|
||||
Last updated January 13th 2022
|
||||
</p>
|
||||
|
||||
<p className="my-4">
|
||||
For the purposes of this document, "We", "our", and "decomp.me" refers to this
|
||||
website, its API, and its administrators.
|
||||
"You" and "user" refers to any person or robot visiting this website.
|
||||
</p>
|
||||
<p className="my-4">
|
||||
For the purposes of this document, "We", "our", and "decomp.me"
|
||||
refers to this website, its API, and its administrators. "You"
|
||||
and "user" refers to any person or robot visiting this website.
|
||||
</p>
|
||||
|
||||
<h2 className={subtitle}>Your privacy</h2>
|
||||
<p className="my-4">
|
||||
We care and respect your right to privacy, and only store data we believe we have
|
||||
legitimate uses for. We have made every effort to ensure that we are compliant with
|
||||
privacy regulations such as GDPR, CCPA, and PECR.
|
||||
</p>
|
||||
<h2 className={subtitle}>Your privacy</h2>
|
||||
<p className="my-4">
|
||||
We care and respect your right to privacy, and only store data
|
||||
we believe we have legitimate uses for. We have made every
|
||||
effort to ensure that we are compliant with privacy regulations
|
||||
such as GDPR, CCPA, and PECR.
|
||||
</p>
|
||||
|
||||
<h2 className={subtitle}>Types of data we collect</h2>
|
||||
<p className="my-4">
|
||||
<b>Logging:</b> decomp.me stores logs when users make requests to
|
||||
decomp.me and its associated API. Data logs are restricted to IP address,
|
||||
request path, and time/date. All logs older than 7 days are automatically
|
||||
deleted in the interests of data minimization.
|
||||
We will only use logs data in exceptional circumstances which we believe to
|
||||
be reasonable, such as to defend against attacks against our servers.
|
||||
Logging IP addresses for the legitimate purpose of security is a widespread practice
|
||||
and does not conflict with privacy regulations.
|
||||
</p>
|
||||
<p className="my-4">
|
||||
<b>Analytics:</b> we use the open source Plausible Analytics software routed through our stats
|
||||
subdomain to count website visits etc.
|
||||
All analytics data collected is publicly available on <Link href="https://stats.decomp.me/decomp.me" className={link}>stats.decomp.me</Link>.
|
||||
All site measurement is carried out absolutely anonymously and in aggregate only.
|
||||
Analytics data collected is limited to:
|
||||
</p>
|
||||
<ul className="mt-2 mb-6 px-4 text-gray-11 text-sm">
|
||||
<li className="my-1">Page URL</li>
|
||||
<li className="my-1">HTTP Referer</li>
|
||||
<li className="my-1">Browser and operating system (using User-Agent HTTP header, which is discarded)</li>
|
||||
<li className="my-1">Device type (using screen width, which is discarded)</li>
|
||||
<li className="my-1">Country, region, city (using IP address, which is then discarded)</li>
|
||||
<li className="my-1">Actions taken on the site, such as compiling or saving a scratch</li>
|
||||
</ul>
|
||||
<p className="my-4">
|
||||
For more information about analytics data, see the <Link href="https://plausible.io/data-policy" className={link}>Plausible Data Policy</Link>.
|
||||
Please note that decomp.me servers, not Plausible, store and process our analytics data.
|
||||
</p>
|
||||
<p className="my-4">
|
||||
<b>Voluntarily-submitted information:</b> decomp.me collects and retains information
|
||||
voluntarily submitted to us. For logged-in users, this includes basic GitHub profile
|
||||
information such as name, email, and avatar. For all users, data submitted on the
|
||||
new scratch page and saved in the scratch editor will be stored and linked to your
|
||||
session.
|
||||
</p>
|
||||
<p className="my-4">
|
||||
<b>Cookies:</b> decomp.me uses a single persistent authentication cookie used to link
|
||||
voluntarily-submitted information to your session on our site. If you are logged in,
|
||||
this cookie will link your session to your account on decomp.me. We do not show any
|
||||
'cookie banners' or 'privacy popups' on decomp.me because we do not use any third-party
|
||||
or analytics cookies.
|
||||
</p>
|
||||
<h2 className={subtitle}>Types of data we collect</h2>
|
||||
<p className="my-4">
|
||||
<b>Logging:</b> decomp.me stores logs when users make requests
|
||||
to decomp.me and its associated API. Data logs are restricted to
|
||||
IP address, request path, and time/date. All logs older than 7
|
||||
days are automatically deleted in the interests of data
|
||||
minimization. We will only use logs data in exceptional
|
||||
circumstances which we believe to be reasonable, such as to
|
||||
defend against attacks against our servers. Logging IP addresses
|
||||
for the legitimate purpose of security is a widespread practice
|
||||
and does not conflict with privacy regulations.
|
||||
</p>
|
||||
<p className="my-4">
|
||||
<b>Analytics:</b> we use the open source Plausible Analytics
|
||||
software routed through our stats subdomain to count website
|
||||
visits etc. All analytics data collected is publicly available
|
||||
on{" "}
|
||||
<Link href="https://stats.decomp.me/decomp.me" className={link}>
|
||||
stats.decomp.me
|
||||
</Link>
|
||||
. All site measurement is carried out absolutely anonymously and
|
||||
in aggregate only. Analytics data collected is limited to:
|
||||
</p>
|
||||
<ul className="mt-2 mb-6 px-4 text-gray-11 text-sm">
|
||||
<li className="my-1">Page URL</li>
|
||||
<li className="my-1">HTTP Referer</li>
|
||||
<li className="my-1">
|
||||
Browser and operating system (using User-Agent HTTP header,
|
||||
which is discarded)
|
||||
</li>
|
||||
<li className="my-1">
|
||||
Device type (using screen width, which is discarded)
|
||||
</li>
|
||||
<li className="my-1">
|
||||
Country, region, city (using IP address, which is then
|
||||
discarded)
|
||||
</li>
|
||||
<li className="my-1">
|
||||
Actions taken on the site, such as compiling or saving a
|
||||
scratch
|
||||
</li>
|
||||
</ul>
|
||||
<p className="my-4">
|
||||
For more information about analytics data, see the{" "}
|
||||
<Link href="https://plausible.io/data-policy" className={link}>
|
||||
Plausible Data Policy
|
||||
</Link>
|
||||
. Please note that decomp.me servers, not Plausible, store and
|
||||
process our analytics data.
|
||||
</p>
|
||||
<p className="my-4">
|
||||
<b>Voluntarily-submitted information:</b> decomp.me collects and
|
||||
retains information voluntarily submitted to us. For logged-in
|
||||
users, this includes basic GitHub profile information such as
|
||||
name, email, and avatar. For all users, data submitted on the
|
||||
new scratch page and saved in the scratch editor will be stored
|
||||
and linked to your session.
|
||||
</p>
|
||||
<p className="my-4">
|
||||
<b>Cookies:</b> decomp.me uses a single persistent
|
||||
authentication cookie used to link voluntarily-submitted
|
||||
information to your session on our site. If you are logged in,
|
||||
this cookie will link your session to your account on decomp.me.
|
||||
We do not show any 'cookie banners' or 'privacy popups' on
|
||||
decomp.me because we do not use any third-party or analytics
|
||||
cookies.
|
||||
</p>
|
||||
|
||||
<h2 className={subtitle}>How data is stored and used</h2>
|
||||
<p className="my-4">
|
||||
decomp.me does not sell, rent, or mine user information under any circumstances.
|
||||
decomp.me's servers are located in Finland. which means that we will
|
||||
transfer, process, and store your information there. In very extreme cases, such as if
|
||||
required by police or other government agencies, data may be disclosed.
|
||||
</p>
|
||||
<p className="my-4">
|
||||
Analytics data is used to prioritise what site features and fixes should be worked
|
||||
on and to let us determine features which are popular or unpopular.
|
||||
</p>
|
||||
<p className="my-4">
|
||||
Voluntarily-submitted information is used to provide vital site features such
|
||||
as user profile pages and the scratch editor. We also reserve the right to use
|
||||
any and all voluntarily-submited information for improving existing decompilation
|
||||
tools and developing new ones. This will not involve sharing information with third parties.
|
||||
</p>
|
||||
<p className="my-4">
|
||||
We make every effort to keep your data secure. In the case of a breach, we will
|
||||
notify you and take appropriate action, such as revoking GitHub OAuth tokens.
|
||||
Please note that our servers never receive or store user passwords.
|
||||
</p>
|
||||
<h2 className={subtitle}>How data is stored and used</h2>
|
||||
<p className="my-4">
|
||||
decomp.me does not sell, rent, or mine user information under
|
||||
any circumstances. decomp.me's servers are located in Finland.
|
||||
which means that we will transfer, process, and store your
|
||||
information there. In very extreme cases, such as if required by
|
||||
police or other government agencies, data may be disclosed.
|
||||
</p>
|
||||
<p className="my-4">
|
||||
Analytics data is used to prioritise what site features and
|
||||
fixes should be worked on and to let us determine features which
|
||||
are popular or unpopular.
|
||||
</p>
|
||||
<p className="my-4">
|
||||
Voluntarily-submitted information is used to provide vital site
|
||||
features such as user profile pages and the scratch editor. We
|
||||
also reserve the right to use any and all voluntarily-submited
|
||||
information for improving existing decompilation tools and
|
||||
developing new ones. This will not involve sharing information
|
||||
with third parties.
|
||||
</p>
|
||||
<p className="my-4">
|
||||
We make every effort to keep your data secure. In the case of a
|
||||
breach, we will notify you and take appropriate action, such as
|
||||
revoking GitHub OAuth tokens. Please note that our servers never
|
||||
receive or store user passwords.
|
||||
</p>
|
||||
|
||||
<h2 className={subtitle}>How to request your data or delete it</h2>
|
||||
<p className="my-4">
|
||||
If you want us to delete some or all data linked to you, please contact us via <Link href="https://discord.gg/sutqNShRRs" className={link}>our Discord server</Link> or <Link href="https://github.com/decompme/decomp.me/issues">GitHub Issues</Link>.
|
||||
You may also want to <Link href="https://github.com/settings/applications" className={link}>disassociate your GitHub account with decomp.me</Link>.
|
||||
</p>
|
||||
<p className="my-4">
|
||||
You may contact us through the same channels linked above if you would like to request
|
||||
a copy of all data linked to you. Similarly, please contact us if you have any questions
|
||||
or concerns regarding this document.
|
||||
</p>
|
||||
</main>
|
||||
<h2 className={subtitle}>How to request your data or delete it</h2>
|
||||
<p className="my-4">
|
||||
If you want us to delete some or all data linked to you, please
|
||||
contact us via{" "}
|
||||
<Link href="https://discord.gg/sutqNShRRs" className={link}>
|
||||
our Discord server
|
||||
</Link>{" "}
|
||||
or{" "}
|
||||
<Link href="https://github.com/decompme/decomp.me/issues">
|
||||
GitHub Issues
|
||||
</Link>
|
||||
. You may also want to{" "}
|
||||
<Link
|
||||
href="https://github.com/settings/applications"
|
||||
className={link}
|
||||
>
|
||||
disassociate your GitHub account with decomp.me
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
<p className="my-4">
|
||||
You may contact us through the same channels linked above if you
|
||||
would like to request a copy of all data linked to you.
|
||||
Similarly, please contact us if you have any questions or
|
||||
concerns regarding this document.
|
||||
</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,32 +1,42 @@
|
||||
import { type ReactNode, useId } from "react"
|
||||
import { type ReactNode, useId } from "react";
|
||||
|
||||
export type Props = {
|
||||
checked: boolean
|
||||
onChange: (checked: boolean) => void
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
|
||||
label: ReactNode
|
||||
description?: ReactNode
|
||||
children?: ReactNode
|
||||
}
|
||||
label: ReactNode;
|
||||
description?: ReactNode;
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
export default function Checkbox({ checked, onChange, label, description, children }: Props) {
|
||||
const id = useId()
|
||||
export default function Checkbox({
|
||||
checked,
|
||||
onChange,
|
||||
label,
|
||||
description,
|
||||
children,
|
||||
}: Props) {
|
||||
const id = useId();
|
||||
|
||||
return <div className="flex gap-2">
|
||||
<div>
|
||||
<input
|
||||
id={id}
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={evt => onChange(evt.target.checked)}
|
||||
/>
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<div>
|
||||
<input
|
||||
id={id}
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(evt) => onChange(evt.target.checked)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grow">
|
||||
<label htmlFor={id} className="select-none font-semibold">
|
||||
{label}
|
||||
</label>
|
||||
{description && (
|
||||
<div className="text-gray-11 text-sm">{description}</div>
|
||||
)}
|
||||
{children && <div className="pt-3">{children}</div>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grow">
|
||||
<label htmlFor={id} className="select-none font-semibold">{label}</label>
|
||||
{description && <div className="text-gray-11 text-sm">{description}</div>}
|
||||
{children && <div className="pt-3">
|
||||
{children}
|
||||
</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,32 +1,34 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react"
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { useSelectedLayoutSegment } from "next/navigation"
|
||||
import { useSelectedLayoutSegment } from "next/navigation";
|
||||
|
||||
import classNames from "classnames"
|
||||
import classNames from "classnames";
|
||||
|
||||
import GhostButton from "@/components/GhostButton"
|
||||
import GhostButton from "@/components/GhostButton";
|
||||
|
||||
export type Props = {
|
||||
segment: string
|
||||
icon: ReactNode
|
||||
label: ReactNode
|
||||
}
|
||||
segment: string;
|
||||
icon: ReactNode;
|
||||
label: ReactNode;
|
||||
};
|
||||
|
||||
export default function NavItem({ segment, label, icon }: Props) {
|
||||
const isSelected = useSelectedLayoutSegment() === segment
|
||||
const isSelected = useSelectedLayoutSegment() === segment;
|
||||
|
||||
return <li className="grow text-center lg:text-left">
|
||||
<GhostButton
|
||||
href={`/settings/${segment}`}
|
||||
className={classNames({
|
||||
"!px-3 block rounded-md py-2": true,
|
||||
"pointer-events-none bg-gray-5 font-medium": isSelected,
|
||||
})}
|
||||
>
|
||||
<span className="mr-2 opacity-50">{icon}</span>
|
||||
{label}
|
||||
</GhostButton>
|
||||
</li>
|
||||
return (
|
||||
<li className="grow text-center lg:text-left">
|
||||
<GhostButton
|
||||
href={`/settings/${segment}`}
|
||||
className={classNames({
|
||||
"!px-3 block rounded-md py-2": true,
|
||||
"pointer-events-none bg-gray-5 font-medium": isSelected,
|
||||
})}
|
||||
>
|
||||
<span className="mr-2 opacity-50">{icon}</span>
|
||||
{label}
|
||||
</GhostButton>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,54 +1,76 @@
|
||||
import { type ReactNode, useId } from "react"
|
||||
import { type ReactNode, useId } from "react";
|
||||
|
||||
function RadioButton({ name, value, checked, onChange, option }: { name: string, value: string, checked: boolean, onChange: (value: string) => void, option: Option }) {
|
||||
const id = useId()
|
||||
function RadioButton({
|
||||
name,
|
||||
value,
|
||||
checked,
|
||||
onChange,
|
||||
option,
|
||||
}: {
|
||||
name: string;
|
||||
value: string;
|
||||
checked: boolean;
|
||||
onChange: (value: string) => void;
|
||||
option: Option;
|
||||
}) {
|
||||
const id = useId();
|
||||
|
||||
return <div className="flex gap-2">
|
||||
<div>
|
||||
<input
|
||||
id={id}
|
||||
name={name}
|
||||
value={value}
|
||||
type="radio"
|
||||
checked={checked}
|
||||
onChange={evt => onChange(evt.target.value)}
|
||||
/>
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<div>
|
||||
<input
|
||||
id={id}
|
||||
name={name}
|
||||
value={value}
|
||||
type="radio"
|
||||
checked={checked}
|
||||
onChange={(evt) => onChange(evt.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grow">
|
||||
<label htmlFor={id} className="select-none font-semibold">
|
||||
{option.label}
|
||||
</label>
|
||||
{option.description && (
|
||||
<div className="text-gray-11 text-sm">
|
||||
{option.description}
|
||||
</div>
|
||||
)}
|
||||
{option.children && (
|
||||
<div className="pt-3">{option.children}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grow">
|
||||
<label htmlFor={id} className="select-none font-semibold">{option.label}</label>
|
||||
{option.description && <div className="text-gray-11 text-sm">{option.description}</div>}
|
||||
{option.children && <div className="pt-3">
|
||||
{option.children}
|
||||
</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export type Option = {
|
||||
label: ReactNode
|
||||
description?: ReactNode
|
||||
children?: ReactNode
|
||||
}
|
||||
label: ReactNode;
|
||||
description?: ReactNode;
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
export type Props = {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
options: { [key: string]: Option }
|
||||
}
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
options: { [key: string]: Option };
|
||||
};
|
||||
|
||||
export default function RadioList({ value, onChange, options }: Props) {
|
||||
const name = useId()
|
||||
const name = useId();
|
||||
|
||||
return <div className="p-1">
|
||||
{Object.keys(options).map(key =>
|
||||
<RadioButton
|
||||
name={name}
|
||||
key={key}
|
||||
value={key}
|
||||
checked={key === value}
|
||||
option={options[key]}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
return (
|
||||
<div className="p-1">
|
||||
{Object.keys(options).map((key) => (
|
||||
<RadioButton
|
||||
name={name}
|
||||
key={key}
|
||||
value={key}
|
||||
checked={key === value}
|
||||
option={options[key]}
|
||||
onChange={onChange}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import type { ReactNode } from "react"
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export type Props = {
|
||||
title: string
|
||||
children: ReactNode
|
||||
}
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export default function Section({ title, children }: Props) {
|
||||
return <section className="mb-8">
|
||||
<h2 className="border-gray-6 border-b py-1 font-semibold text-2xl">{title}</h2>
|
||||
<div className="pt-4">
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
return (
|
||||
<section className="mb-8">
|
||||
<h2 className="border-gray-6 border-b py-1 font-semibold text-2xl">
|
||||
{title}
|
||||
</h2>
|
||||
<div className="pt-4">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,66 +1,86 @@
|
||||
import { useId, type ReactNode } from "react"
|
||||
import { useId, type ReactNode } from "react";
|
||||
|
||||
import classNames from "classnames"
|
||||
import classNames from "classnames";
|
||||
|
||||
import NumberInput from "@/components/NumberInput"
|
||||
import NumberInput from "@/components/NumberInput";
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(Math.max(value, min), max)
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
export type Props = {
|
||||
value: number
|
||||
onChange: (value: number) => void
|
||||
disabled?: boolean
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
disabled?: boolean;
|
||||
|
||||
label: ReactNode
|
||||
description?: ReactNode
|
||||
unit?: ReactNode
|
||||
label: ReactNode;
|
||||
description?: ReactNode;
|
||||
unit?: ReactNode;
|
||||
|
||||
min: number
|
||||
max: number
|
||||
step: number
|
||||
}
|
||||
min: number;
|
||||
max: number;
|
||||
step: number;
|
||||
};
|
||||
|
||||
export default function SliderField({ value, onChange, disabled, label, description, unit, min, max, step }: Props) {
|
||||
const id = useId()
|
||||
export default function SliderField({
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
label,
|
||||
description,
|
||||
unit,
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
}: Props) {
|
||||
const id = useId();
|
||||
|
||||
return <div
|
||||
className={classNames({
|
||||
"cursor-not-allowed opacity-50": disabled,
|
||||
})}
|
||||
>
|
||||
<label htmlFor={id} className="select-none font-semibold">
|
||||
{label}
|
||||
</label>
|
||||
return (
|
||||
<div
|
||||
className={classNames({
|
||||
"cursor-not-allowed opacity-50": disabled,
|
||||
})}
|
||||
>
|
||||
<label htmlFor={id} className="select-none font-semibold">
|
||||
{label}
|
||||
</label>
|
||||
|
||||
<div className="mt-1 select-none text-gray-11">
|
||||
<div className="inline-block w-1/6 font-medium">
|
||||
<NumberInput
|
||||
value={value}
|
||||
onChange={newValue => onChange(clamp(newValue, min, max))}
|
||||
disabled={disabled}
|
||||
/>
|
||||
{unit}
|
||||
<div className="mt-1 select-none text-gray-11">
|
||||
<div className="inline-block w-1/6 font-medium">
|
||||
<NumberInput
|
||||
value={value}
|
||||
onChange={(newValue) =>
|
||||
onChange(clamp(newValue, min, max))
|
||||
}
|
||||
disabled={disabled}
|
||||
/>
|
||||
{unit}
|
||||
</div>
|
||||
|
||||
<div className="inline-flex w-5/6 items-center gap-2 text-gray-10 text-xs">
|
||||
{min}
|
||||
{unit}
|
||||
<input
|
||||
id={id}
|
||||
type="range"
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={value}
|
||||
onChange={(evt) =>
|
||||
onChange(clamp(+evt.target.value, min, max))
|
||||
}
|
||||
disabled={disabled}
|
||||
className="w-full focus:ring"
|
||||
/>
|
||||
{max}
|
||||
{unit}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="inline-flex w-5/6 items-center gap-2 text-gray-10 text-xs">
|
||||
{min}{unit}
|
||||
<input
|
||||
id={id}
|
||||
type="range"
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={value}
|
||||
onChange={evt => onChange(clamp(+evt.target.value, min, max))}
|
||||
disabled={disabled}
|
||||
className="w-full focus:ring"
|
||||
/>
|
||||
{max}{unit}
|
||||
</div>
|
||||
{description && (
|
||||
<div className="mt-1 text-gray-11 text-sm">{description}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{description && <div className="mt-1 text-gray-11 text-sm">{description}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,42 +1,54 @@
|
||||
import { useId, type ReactNode, type CSSProperties } from "react"
|
||||
import { useId, type ReactNode, type CSSProperties } from "react";
|
||||
|
||||
import classNames from "classnames"
|
||||
import classNames from "classnames";
|
||||
|
||||
export type Props = {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
disabled?: boolean
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
|
||||
label: ReactNode
|
||||
description?: ReactNode
|
||||
label: ReactNode;
|
||||
description?: ReactNode;
|
||||
|
||||
placeholder?: string
|
||||
placeholder?: string;
|
||||
|
||||
inputStyle?: CSSProperties
|
||||
}
|
||||
|
||||
export default function TextField({ value, onChange, disabled, label, description, placeholder, inputStyle }: Props) {
|
||||
const id = useId()
|
||||
|
||||
return <div
|
||||
className={classNames({
|
||||
"cursor-not-allowed opacity-50": disabled,
|
||||
})}
|
||||
>
|
||||
<label htmlFor={id} className="select-none font-semibold">
|
||||
{label}
|
||||
</label>
|
||||
{description && <div className="mt-1 text-gray-11 text-sm">{description}</div>}
|
||||
<input
|
||||
id={id}
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={evt => onChange(evt.target.value)}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
spellCheck={false}
|
||||
className="mt-1 block w-full rounded border border-gray-6 bg-transparent px-2.5 py-1.5 text-gray-11 text-sm outline-none focus:text-gray-12 focus:placeholder:text-gray-10"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
inputStyle?: CSSProperties;
|
||||
};
|
||||
|
||||
export default function TextField({
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
label,
|
||||
description,
|
||||
placeholder,
|
||||
inputStyle,
|
||||
}: Props) {
|
||||
const id = useId();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames({
|
||||
"cursor-not-allowed opacity-50": disabled,
|
||||
})}
|
||||
>
|
||||
<label htmlFor={id} className="select-none font-semibold">
|
||||
{label}
|
||||
</label>
|
||||
{description && (
|
||||
<div className="mt-1 text-gray-11 text-sm">{description}</div>
|
||||
)}
|
||||
<input
|
||||
id={id}
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(evt) => onChange(evt.target.value)}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
spellCheck={false}
|
||||
className="mt-1 block w-full rounded border border-gray-6 bg-transparent px-2.5 py-1.5 text-gray-11 text-sm outline-none focus:text-gray-12 focus:placeholder:text-gray-10"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,32 +1,37 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import { LinkExternalIcon } from "@primer/octicons-react"
|
||||
import { LinkExternalIcon } from "@primer/octicons-react";
|
||||
|
||||
import Button from "@/components/Button"
|
||||
import GhostButton from "@/components/GhostButton"
|
||||
import { useThisUser, isAnonUser } from "@/lib/api"
|
||||
import { userHtmlUrl } from "@/lib/api/urls"
|
||||
import Button from "@/components/Button";
|
||||
import GhostButton from "@/components/GhostButton";
|
||||
import { useThisUser, isAnonUser } from "@/lib/api";
|
||||
import { userHtmlUrl } from "@/lib/api/urls";
|
||||
|
||||
import Section from "../Section"
|
||||
import Section from "../Section";
|
||||
|
||||
export default function ProfileSection() {
|
||||
const user = useThisUser()
|
||||
const user = useThisUser();
|
||||
|
||||
// No profile section for anonymous users
|
||||
if (!user || isAnonUser(user)) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
return <Section title="Profile">
|
||||
<p>
|
||||
Your name and profile picture are controlled by your GitHub account.
|
||||
</p>
|
||||
<div className="flex items-center gap-2 py-4">
|
||||
<Button href="https://github.com/settings/profile">
|
||||
Edit on GitHub
|
||||
<LinkExternalIcon />
|
||||
</Button>
|
||||
<GhostButton href={userHtmlUrl(user)}>View decomp.me profile</GhostButton>
|
||||
</div>
|
||||
</Section>
|
||||
return (
|
||||
<Section title="Profile">
|
||||
<p>
|
||||
Your name and profile picture are controlled by your GitHub
|
||||
account.
|
||||
</p>
|
||||
<div className="flex items-center gap-2 py-4">
|
||||
<Button href="https://github.com/settings/profile">
|
||||
Edit on GitHub
|
||||
<LinkExternalIcon />
|
||||
</Button>
|
||||
<GhostButton href={userHtmlUrl(user)}>
|
||||
View decomp.me profile
|
||||
</GhostButton>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import { mutate } from "swr"
|
||||
import { mutate } from "swr";
|
||||
|
||||
import AsyncButton from "@/components/AsyncButton"
|
||||
import { useThisUser, isAnonUser } from "@/lib/api"
|
||||
import { post } from "@/lib/api/request"
|
||||
import AsyncButton from "@/components/AsyncButton";
|
||||
import { useThisUser, isAnonUser } from "@/lib/api";
|
||||
import { post } from "@/lib/api/request";
|
||||
|
||||
export default function SignOutButton() {
|
||||
const user = useThisUser()
|
||||
const isAnon = user && isAnonUser(user)
|
||||
const user = useThisUser();
|
||||
const isAnon = user && isAnonUser(user);
|
||||
|
||||
return <AsyncButton
|
||||
onClick={async () => {
|
||||
const user = await post("/user", {})
|
||||
await mutate("/user", user)
|
||||
}}
|
||||
>
|
||||
{isAnon ? "Reset anonymous appearance" : "Sign out"}
|
||||
</AsyncButton>
|
||||
return (
|
||||
<AsyncButton
|
||||
onClick={async () => {
|
||||
const user = await post("/user", {});
|
||||
await mutate("/user", user);
|
||||
}}
|
||||
>
|
||||
{isAnon ? "Reset anonymous appearance" : "Sign out"}
|
||||
</AsyncButton>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,31 +1,38 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import GitHubLoginButton from "@/components/GitHubLoginButton"
|
||||
import LoadingSpinner from "@/components/loading.svg"
|
||||
import UserAvatar from "@/components/user/UserAvatar"
|
||||
import UserMention from "@/components/user/UserMention"
|
||||
import { isAnonUser, useThisUser } from "@/lib/api"
|
||||
import GitHubLoginButton from "@/components/GitHubLoginButton";
|
||||
import LoadingSpinner from "@/components/loading.svg";
|
||||
import UserAvatar from "@/components/user/UserAvatar";
|
||||
import UserMention from "@/components/user/UserMention";
|
||||
import { isAnonUser, useThisUser } from "@/lib/api";
|
||||
|
||||
import SignOutButton from "./SignOutButton"
|
||||
import SignOutButton from "./SignOutButton";
|
||||
|
||||
export default function UserState() {
|
||||
const user = useThisUser()
|
||||
const user = useThisUser();
|
||||
|
||||
return <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-4">
|
||||
<div>
|
||||
<UserAvatar user={user} className="size-16" />
|
||||
</div>
|
||||
{user ? <div>
|
||||
<p>
|
||||
{isAnonUser(user) ? "You appear as" : "Signed in as"} <UserMention user={user} />
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2 pt-2">
|
||||
{isAnonUser(user) && <GitHubLoginButton />}
|
||||
<SignOutButton />
|
||||
return (
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-4">
|
||||
<div>
|
||||
<UserAvatar user={user} className="size-16" />
|
||||
</div>
|
||||
</div> : <div className="flex animate-pulse items-center gap-2">
|
||||
<LoadingSpinner className="size-6" />
|
||||
<span>Loading...</span>
|
||||
</div>}
|
||||
</div>
|
||||
{user ? (
|
||||
<div>
|
||||
<p>
|
||||
{isAnonUser(user) ? "You appear as" : "Signed in as"}{" "}
|
||||
<UserMention user={user} />
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2 pt-2">
|
||||
{isAnonUser(user) && <GitHubLoginButton />}
|
||||
<SignOutButton />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex animate-pulse items-center gap-2">
|
||||
<LoadingSpinner className="size-6" />
|
||||
<span>Loading...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
import Section from "../Section"
|
||||
import Section from "../Section";
|
||||
|
||||
import ProfileSection from "./ProfileSection"
|
||||
import UserState from "./UserState"
|
||||
import ProfileSection from "./ProfileSection";
|
||||
import UserState from "./UserState";
|
||||
|
||||
export const metadata = {
|
||||
title: "Account settings",
|
||||
}
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
return <>
|
||||
<Section title="Account">
|
||||
<UserState />
|
||||
</Section>
|
||||
<ProfileSection />
|
||||
</>
|
||||
return (
|
||||
<>
|
||||
<Section title="Account">
|
||||
<UserState />
|
||||
</Section>
|
||||
<ProfileSection />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,61 +1,89 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic"
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
import ColorSchemePicker from "@/components/ColorSchemePicker"
|
||||
import LoadingSpinner from "@/components/loading.svg"
|
||||
import ThemePicker from "@/components/ThemePicker"
|
||||
import * as settings from "@/lib/settings"
|
||||
import ColorSchemePicker from "@/components/ColorSchemePicker";
|
||||
import LoadingSpinner from "@/components/loading.svg";
|
||||
import ThemePicker from "@/components/ThemePicker";
|
||||
import * as settings from "@/lib/settings";
|
||||
|
||||
import Section from "../Section"
|
||||
import SliderField from "../SliderField"
|
||||
import TextField from "../TextField"
|
||||
import Section from "../Section";
|
||||
import SliderField from "../SliderField";
|
||||
import TextField from "../TextField";
|
||||
|
||||
const DynamicExampleCodeMirror = dynamic(() => import("./ExampleCodeMirror"), {
|
||||
loading: () => <div className="flex animate-pulse items-center justify-center" style={{ height: "200px" }}>
|
||||
<LoadingSpinner className="size-16 opacity-50" />
|
||||
</div>,
|
||||
})
|
||||
loading: () => (
|
||||
<div
|
||||
className="flex animate-pulse items-center justify-center"
|
||||
style={{ height: "200px" }}
|
||||
>
|
||||
<LoadingSpinner className="size-16 opacity-50" />
|
||||
</div>
|
||||
),
|
||||
});
|
||||
|
||||
export default function AppearanceSettings() {
|
||||
const [theme, setTheme] = settings.useTheme()
|
||||
const [fontSize, setFontSize] = settings.useCodeFontSize()
|
||||
const [monospaceFont, setMonospaceFont] = settings.useMonospaceFont()
|
||||
const [codeLineHeight, setCodeLineHeight] = settings.useCodeLineHeight()
|
||||
const [codeColorScheme, setCodeColorScheme] = settings.useCodeColorScheme()
|
||||
const [theme, setTheme] = settings.useTheme();
|
||||
const [fontSize, setFontSize] = settings.useCodeFontSize();
|
||||
const [monospaceFont, setMonospaceFont] = settings.useMonospaceFont();
|
||||
const [codeLineHeight, setCodeLineHeight] = settings.useCodeLineHeight();
|
||||
const [codeColorScheme, setCodeColorScheme] = settings.useCodeColorScheme();
|
||||
|
||||
return <>
|
||||
<Section title="Theme">
|
||||
<ThemePicker theme={theme} onChange={setTheme} />
|
||||
</Section>
|
||||
<Section title="Code">
|
||||
<div className="mb-6 flex-row gap-6 md:flex">
|
||||
<div className="mb-6 md:mb-0 md:w-1/2">
|
||||
<SliderField label="Font size" min={8} max={24} step={1} unit="px" value={fontSize} onChange={setFontSize} />
|
||||
return (
|
||||
<>
|
||||
<Section title="Theme">
|
||||
<ThemePicker theme={theme} onChange={setTheme} />
|
||||
</Section>
|
||||
<Section title="Code">
|
||||
<div className="mb-6 flex-row gap-6 md:flex">
|
||||
<div className="mb-6 md:mb-0 md:w-1/2">
|
||||
<SliderField
|
||||
label="Font size"
|
||||
min={8}
|
||||
max={24}
|
||||
step={1}
|
||||
unit="px"
|
||||
value={fontSize}
|
||||
onChange={setFontSize}
|
||||
/>
|
||||
</div>
|
||||
<div className="md:w-1/2">
|
||||
<SliderField
|
||||
label="Line height"
|
||||
min={0.5}
|
||||
max={2}
|
||||
step={0.05}
|
||||
unit="x"
|
||||
value={codeLineHeight}
|
||||
onChange={setCodeLineHeight}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="md:w-1/2">
|
||||
<SliderField label="Line height" min={0.5} max={2} step={0.05} unit="x" value={codeLineHeight} onChange={setCodeLineHeight} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-6 max-w-xl">
|
||||
<TextField
|
||||
label="Font family"
|
||||
description="The font family to use for code. The first valid comma-separated value will be used."
|
||||
value={monospaceFont ?? ""}
|
||||
onChange={setMonospaceFont}
|
||||
placeholder="ui-monospace"
|
||||
inputStyle={{ fontFamily: `${monospaceFont ?? "ui-monospace"}, monospace` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<div className="font-semibold">Color scheme</div>
|
||||
<div className="my-2 overflow-hidden rounded border border-gray-6">
|
||||
<DynamicExampleCodeMirror />
|
||||
<div className="mb-6 max-w-xl">
|
||||
<TextField
|
||||
label="Font family"
|
||||
description="The font family to use for code. The first valid comma-separated value will be used."
|
||||
value={monospaceFont ?? ""}
|
||||
onChange={setMonospaceFont}
|
||||
placeholder="ui-monospace"
|
||||
inputStyle={{
|
||||
fontFamily: `${monospaceFont ?? "ui-monospace"}, monospace`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<ColorSchemePicker scheme={codeColorScheme} onChange={setCodeColorScheme} />
|
||||
</div>
|
||||
</Section>
|
||||
</>
|
||||
|
||||
<div className="mb-6">
|
||||
<div className="font-semibold">Color scheme</div>
|
||||
<div className="my-2 overflow-hidden rounded border border-gray-6">
|
||||
<DynamicExampleCodeMirror />
|
||||
</div>
|
||||
<ColorSchemePicker
|
||||
scheme={codeColorScheme}
|
||||
onChange={setCodeColorScheme}
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import CodeMirror from "@/components/Editor/CodeMirror"
|
||||
import basicSetup from "@/lib/codemirror/basic-setup"
|
||||
import { cpp } from "@/lib/codemirror/cpp"
|
||||
import CodeMirror from "@/components/Editor/CodeMirror";
|
||||
import basicSetup from "@/lib/codemirror/basic-setup";
|
||||
import { cpp } from "@/lib/codemirror/cpp";
|
||||
|
||||
import styles from "./ExampleCodeMirror.module.scss"
|
||||
import styles from "./ExampleCodeMirror.module.scss";
|
||||
|
||||
const EXAMPLE_C_CODE = `#include "common.h"
|
||||
|
||||
@@ -110,14 +110,16 @@ void step_game_loop(void) {
|
||||
|
||||
rand_int(1);
|
||||
}
|
||||
`
|
||||
`;
|
||||
|
||||
export default function ExampleCodeMirror() {
|
||||
return <div className={styles.container}>
|
||||
<CodeMirror
|
||||
value={EXAMPLE_C_CODE}
|
||||
valueVersion={0}
|
||||
extensions={[basicSetup, cpp()]}
|
||||
/>
|
||||
</div>
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<CodeMirror
|
||||
value={EXAMPLE_C_CODE}
|
||||
valueVersion={0}
|
||||
extensions={[basicSetup, cpp()]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import AppearanceSettings from "./AppearanceSettings"
|
||||
import AppearanceSettings from "./AppearanceSettings";
|
||||
|
||||
export const metadata = {
|
||||
title: "Appearance settings",
|
||||
}
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
return <>
|
||||
<AppearanceSettings />
|
||||
</>
|
||||
return (
|
||||
<>
|
||||
<AppearanceSettings />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,122 +1,156 @@
|
||||
"use client"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
"use client";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import LoadingSpinner from "@/components/loading.svg"
|
||||
import LoadingSpinner from "@/components/loading.svg";
|
||||
import {
|
||||
ThreeWayDiffBase, useAutoRecompileSetting, useAutoRecompileDelaySetting, useMatchProgressBarEnabled,
|
||||
useLanguageServerEnabled, useVimModeEnabled, useThreeWayDiffBase, useObjdiffClientEnabled,
|
||||
} from "@/lib/settings"
|
||||
ThreeWayDiffBase,
|
||||
useAutoRecompileSetting,
|
||||
useAutoRecompileDelaySetting,
|
||||
useMatchProgressBarEnabled,
|
||||
useLanguageServerEnabled,
|
||||
useVimModeEnabled,
|
||||
useThreeWayDiffBase,
|
||||
useObjdiffClientEnabled,
|
||||
} from "@/lib/settings";
|
||||
|
||||
import Checkbox from "../Checkbox"
|
||||
import RadioList from "../RadioList"
|
||||
import Section from "../Section"
|
||||
import SliderField from "../SliderField"
|
||||
import Checkbox from "../Checkbox";
|
||||
import RadioList from "../RadioList";
|
||||
import Section from "../Section";
|
||||
import SliderField from "../SliderField";
|
||||
|
||||
export default function EditorSettings() {
|
||||
const [autoRecompile, setAutoRecompile] = useAutoRecompileSetting()
|
||||
const [autoRecompileDelay, setAutoRecompileDelay] = useAutoRecompileDelaySetting()
|
||||
const [matchProgressBarEnabled, setMatchProgressBarEnabled] = useMatchProgressBarEnabled()
|
||||
const [languageServerEnabled, setLanguageServerEnabled] = useLanguageServerEnabled()
|
||||
const [vimModeEnabled, setVimModeEnabled] = useVimModeEnabled()
|
||||
const [threeWayDiffBase, setThreeWayDiffBase] = useThreeWayDiffBase()
|
||||
const [objdiffClientEnabled, setObjdiffClientEnabled] = useObjdiffClientEnabled()
|
||||
const [autoRecompile, setAutoRecompile] = useAutoRecompileSetting();
|
||||
const [autoRecompileDelay, setAutoRecompileDelay] =
|
||||
useAutoRecompileDelaySetting();
|
||||
const [matchProgressBarEnabled, setMatchProgressBarEnabled] =
|
||||
useMatchProgressBarEnabled();
|
||||
const [languageServerEnabled, setLanguageServerEnabled] =
|
||||
useLanguageServerEnabled();
|
||||
const [vimModeEnabled, setVimModeEnabled] = useVimModeEnabled();
|
||||
const [threeWayDiffBase, setThreeWayDiffBase] = useThreeWayDiffBase();
|
||||
const [objdiffClientEnabled, setObjdiffClientEnabled] =
|
||||
useObjdiffClientEnabled();
|
||||
|
||||
const [downloadingLanguageServer, setDownloadingLanguageServer] = useState(false)
|
||||
const [downloadingLanguageServer, setDownloadingLanguageServer] =
|
||||
useState(false);
|
||||
|
||||
const isInitialMount = useRef(true)
|
||||
const isInitialMount = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
// Prevent the language server binary from being downloaded if the user has it enabled, then enters settings
|
||||
if (isInitialMount.current) {
|
||||
isInitialMount.current = false
|
||||
return
|
||||
isInitialMount.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (languageServerEnabled) {
|
||||
setDownloadingLanguageServer(true)
|
||||
setDownloadingLanguageServer(true);
|
||||
|
||||
import("@clangd-wasm/clangd-wasm").then(({ ClangdStdioTransport }) => {
|
||||
// We don't need to do anything with the result of this fetch - all this
|
||||
// is is a way to make sure the wasm file ends up in the browser's cache.
|
||||
fetch(ClangdStdioTransport.getDefaultWasmURL(false))
|
||||
.then(res => res.blob())
|
||||
.then(() => setDownloadingLanguageServer(false))
|
||||
})
|
||||
import("@clangd-wasm/clangd-wasm").then(
|
||||
({ ClangdStdioTransport }) => {
|
||||
// We don't need to do anything with the result of this fetch - all this
|
||||
// is is a way to make sure the wasm file ends up in the browser's cache.
|
||||
fetch(ClangdStdioTransport.getDefaultWasmURL(false))
|
||||
.then((res) => res.blob())
|
||||
.then(() => setDownloadingLanguageServer(false));
|
||||
},
|
||||
);
|
||||
}
|
||||
}, [languageServerEnabled])
|
||||
}, [languageServerEnabled]);
|
||||
|
||||
const threeWayDiffOptions = {
|
||||
[ThreeWayDiffBase.SAVED]: { label: <div>Latest save ( <span className="font-mono text-gray-11">diff.py -b</span> )</div> },
|
||||
[ThreeWayDiffBase.PREV]: { label: <div>Previous result ( <span className="font-mono text-gray-11">diff.py -3</span> )</div> },
|
||||
}
|
||||
|
||||
return <>
|
||||
<Section title="Automatic compilation">
|
||||
<Checkbox
|
||||
checked={autoRecompile}
|
||||
onChange={setAutoRecompile}
|
||||
label="Automatically compile after changes to scratch"
|
||||
description="Automatically recompile your code a short period of time after you stop typing."
|
||||
>
|
||||
<div className="max-w-prose text-sm">
|
||||
<SliderField
|
||||
value={autoRecompileDelay}
|
||||
onChange={setAutoRecompileDelay}
|
||||
disabled={!autoRecompile}
|
||||
label="Delay before recompile is triggered"
|
||||
unit="ms"
|
||||
min={100}
|
||||
max={2000}
|
||||
step={50}
|
||||
/>
|
||||
[ThreeWayDiffBase.SAVED]: {
|
||||
label: (
|
||||
<div>
|
||||
Latest save ({" "}
|
||||
<span className="font-mono text-gray-11">diff.py -b</span> )
|
||||
</div>
|
||||
</Checkbox>
|
||||
</Section>
|
||||
<Section title="Three-way diffing target">
|
||||
<div className="text-gray-11">
|
||||
When enabling three-way diffing for a scratch, let the third column show a diff against:
|
||||
</div>
|
||||
<RadioList
|
||||
value={threeWayDiffBase}
|
||||
onChange={(value: string) => {
|
||||
setThreeWayDiffBase(value as ThreeWayDiffBase)
|
||||
}}
|
||||
options={threeWayDiffOptions}
|
||||
/>
|
||||
</Section>
|
||||
<Section title="Match progress bar">
|
||||
<Checkbox
|
||||
checked={matchProgressBarEnabled}
|
||||
onChange={setMatchProgressBarEnabled}
|
||||
label="Show progress bar on scratch editor"
|
||||
description="Show a progress bar at the top of the editor to visually display the match percent of a scratch."
|
||||
/>
|
||||
</Section>
|
||||
<Section title="Language server">
|
||||
<Checkbox
|
||||
checked={languageServerEnabled}
|
||||
onChange={setLanguageServerEnabled}
|
||||
label="Enable language server"
|
||||
description="Enable editor features such as code completion, error checking, and formatting via clangd and WebAssembly magic. WARNING: enabling will incur a one time ~13MB download, and bump up resource usage during editing.">
|
||||
),
|
||||
},
|
||||
[ThreeWayDiffBase.PREV]: {
|
||||
label: (
|
||||
<div>
|
||||
Previous result ({" "}
|
||||
<span className="font-mono text-gray-11">diff.py -3</span> )
|
||||
</div>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
{downloadingLanguageServer && <div className="flex gap-2 p-4"><LoadingSpinner width="24px" /> Downloading...</div>}
|
||||
</Checkbox>
|
||||
</Section>
|
||||
<Section title="Vim Mode">
|
||||
<Checkbox
|
||||
checked={vimModeEnabled}
|
||||
onChange={setVimModeEnabled}
|
||||
label="Enable vim bindings"
|
||||
description="Enable vim bindings in the scratch editor">
|
||||
</Checkbox>
|
||||
</Section>
|
||||
<Section title="Experiments">
|
||||
<Checkbox
|
||||
checked={objdiffClientEnabled}
|
||||
onChange={setObjdiffClientEnabled}
|
||||
label="Enable objdiff in WebAssembly"
|
||||
description="WARNING: For development use only. Runs objdiff locally for diffing. Platform support is limited, and certain features will be broken. Download size: ~3.5MB">
|
||||
</Checkbox>
|
||||
</Section>
|
||||
</>
|
||||
return (
|
||||
<>
|
||||
<Section title="Automatic compilation">
|
||||
<Checkbox
|
||||
checked={autoRecompile}
|
||||
onChange={setAutoRecompile}
|
||||
label="Automatically compile after changes to scratch"
|
||||
description="Automatically recompile your code a short period of time after you stop typing."
|
||||
>
|
||||
<div className="max-w-prose text-sm">
|
||||
<SliderField
|
||||
value={autoRecompileDelay}
|
||||
onChange={setAutoRecompileDelay}
|
||||
disabled={!autoRecompile}
|
||||
label="Delay before recompile is triggered"
|
||||
unit="ms"
|
||||
min={100}
|
||||
max={2000}
|
||||
step={50}
|
||||
/>
|
||||
</div>
|
||||
</Checkbox>
|
||||
</Section>
|
||||
<Section title="Three-way diffing target">
|
||||
<div className="text-gray-11">
|
||||
When enabling three-way diffing for a scratch, let the third
|
||||
column show a diff against:
|
||||
</div>
|
||||
<RadioList
|
||||
value={threeWayDiffBase}
|
||||
onChange={(value: string) => {
|
||||
setThreeWayDiffBase(value as ThreeWayDiffBase);
|
||||
}}
|
||||
options={threeWayDiffOptions}
|
||||
/>
|
||||
</Section>
|
||||
<Section title="Match progress bar">
|
||||
<Checkbox
|
||||
checked={matchProgressBarEnabled}
|
||||
onChange={setMatchProgressBarEnabled}
|
||||
label="Show progress bar on scratch editor"
|
||||
description="Show a progress bar at the top of the editor to visually display the match percent of a scratch."
|
||||
/>
|
||||
</Section>
|
||||
<Section title="Language server">
|
||||
<Checkbox
|
||||
checked={languageServerEnabled}
|
||||
onChange={setLanguageServerEnabled}
|
||||
label="Enable language server"
|
||||
description="Enable editor features such as code completion, error checking, and formatting via clangd and WebAssembly magic. WARNING: enabling will incur a one time ~13MB download, and bump up resource usage during editing."
|
||||
>
|
||||
{downloadingLanguageServer && (
|
||||
<div className="flex gap-2 p-4">
|
||||
<LoadingSpinner width="24px" /> Downloading...
|
||||
</div>
|
||||
)}
|
||||
</Checkbox>
|
||||
</Section>
|
||||
<Section title="Vim Mode">
|
||||
<Checkbox
|
||||
checked={vimModeEnabled}
|
||||
onChange={setVimModeEnabled}
|
||||
label="Enable vim bindings"
|
||||
description="Enable vim bindings in the scratch editor"
|
||||
/>
|
||||
</Section>
|
||||
<Section title="Experiments">
|
||||
<Checkbox
|
||||
checked={objdiffClientEnabled}
|
||||
onChange={setObjdiffClientEnabled}
|
||||
label="Enable objdiff in WebAssembly"
|
||||
description="WARNING: For development use only. Runs objdiff locally for diffing. Platform support is limited, and certain features will be broken. Download size: ~3.5MB"
|
||||
/>
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import EditorSettings from "./EditorSettings"
|
||||
import EditorSettings from "./EditorSettings";
|
||||
|
||||
export const metadata = {
|
||||
title: "Editor settings",
|
||||
}
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
return <>
|
||||
<EditorSettings />
|
||||
</>
|
||||
return (
|
||||
<>
|
||||
<EditorSettings />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,24 +1,38 @@
|
||||
import { FileIcon, PaintbrushIcon, GearIcon } from "@primer/octicons-react"
|
||||
import { FileIcon, PaintbrushIcon, GearIcon } from "@primer/octicons-react";
|
||||
|
||||
import NavItem from "./NavItem"
|
||||
import NavItem from "./NavItem";
|
||||
|
||||
export default function Layout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <div className="mx-auto flex w-full max-w-screen-lg flex-col lg:flex-row">
|
||||
<aside className="mx-auto w-full max-w-screen-md border-gray-6 border-b p-1 lg:w-1/4 lg:border-b-0 lg:p-6">
|
||||
<nav aria-label="Settings">
|
||||
<ul className="flex flex-wrap gap-1 lg:flex-col">
|
||||
<NavItem segment="account" label="Account" icon={<GearIcon />} />
|
||||
<NavItem segment="appearance" label="Appearance" icon={<PaintbrushIcon />} />
|
||||
<NavItem segment="editor" label="Editor" icon={<FileIcon />} />
|
||||
</ul>
|
||||
</nav>
|
||||
</aside>
|
||||
<main className="mx-auto w-full max-w-screen-md p-6 lg:w-3/4">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-screen-lg flex-col lg:flex-row">
|
||||
<aside className="mx-auto w-full max-w-screen-md border-gray-6 border-b p-1 lg:w-1/4 lg:border-b-0 lg:p-6">
|
||||
<nav aria-label="Settings">
|
||||
<ul className="flex flex-wrap gap-1 lg:flex-col">
|
||||
<NavItem
|
||||
segment="account"
|
||||
label="Account"
|
||||
icon={<GearIcon />}
|
||||
/>
|
||||
<NavItem
|
||||
segment="appearance"
|
||||
label="Appearance"
|
||||
icon={<PaintbrushIcon />}
|
||||
/>
|
||||
<NavItem
|
||||
segment="editor"
|
||||
label="Editor"
|
||||
icon={<FileIcon />}
|
||||
/>
|
||||
</ul>
|
||||
</nav>
|
||||
</aside>
|
||||
<main className="mx-auto w-full max-w-screen-md p-6 lg:w-3/4">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,22 +1,24 @@
|
||||
import type { Metadata } from "next"
|
||||
import type { Metadata } from "next";
|
||||
|
||||
import { notFound } from "next/navigation"
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import Profile from "@/components/user/Profile"
|
||||
import { get } from "@/lib/api/request"
|
||||
import type { User } from "@/lib/api/types"
|
||||
import Profile from "@/components/user/Profile";
|
||||
import { get } from "@/lib/api/request";
|
||||
import type { User } from "@/lib/api/types";
|
||||
|
||||
export async function generateMetadata({ params }: { params: { username: string } }): Promise<Metadata> {
|
||||
let user: User
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: { params: { username: string } }): Promise<Metadata> {
|
||||
let user: User;
|
||||
|
||||
try {
|
||||
user = await get(`/users/${params.username}`)
|
||||
user = await get(`/users/${params.username}`);
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return notFound()
|
||||
return notFound();
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -24,20 +26,22 @@ export async function generateMetadata({ params }: { params: { username: string
|
||||
openGraph: {
|
||||
title: user.username,
|
||||
},
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default async function Page({ params }: { params: { username: string } }) {
|
||||
let user: User
|
||||
export default async function Page({
|
||||
params,
|
||||
}: { params: { username: string } }) {
|
||||
let user: User;
|
||||
try {
|
||||
user = await get(`/users/${params.username}`)
|
||||
user = await get(`/users/${params.username}`);
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return notFound()
|
||||
return notFound();
|
||||
}
|
||||
|
||||
return <Profile user={user} />
|
||||
return <Profile user={user} />;
|
||||
}
|
||||
|
||||
@@ -1,48 +1,54 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react"
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { applyColorScheme } from "@/lib/codemirror/color-scheme"
|
||||
import * as settings from "@/lib/settings"
|
||||
import { applyColorScheme } from "@/lib/codemirror/color-scheme";
|
||||
import * as settings from "@/lib/settings";
|
||||
|
||||
export default function ThemeProvider() {
|
||||
const [codeColorScheme, setCodeColorScheme] = settings.useCodeColorScheme()
|
||||
const [codeColorScheme, setCodeColorScheme] = settings.useCodeColorScheme();
|
||||
useEffect(() => {
|
||||
applyColorScheme(codeColorScheme)
|
||||
}, [codeColorScheme])
|
||||
applyColorScheme(codeColorScheme);
|
||||
}, [codeColorScheme]);
|
||||
|
||||
const isSiteThemeDark = settings.useIsSiteThemeDark()
|
||||
const isSiteThemeDark = settings.useIsSiteThemeDark();
|
||||
useEffect(() => {
|
||||
// Apply theme
|
||||
if (isSiteThemeDark) {
|
||||
document.documentElement.classList.add("dark")
|
||||
document.documentElement.classList.add("dark");
|
||||
} else {
|
||||
document.documentElement.classList.remove("dark")
|
||||
document.documentElement.classList.remove("dark");
|
||||
}
|
||||
|
||||
// If using the default code color scheme (Frog), pick the variant that matches the site theme
|
||||
setCodeColorScheme(current => {
|
||||
setCodeColorScheme((current) => {
|
||||
if (current === "Frog Dark" || current === "Frog Light") {
|
||||
return isSiteThemeDark ? "Frog Dark" : "Frog Light"
|
||||
return isSiteThemeDark ? "Frog Dark" : "Frog Light";
|
||||
} else {
|
||||
return current
|
||||
return current;
|
||||
}
|
||||
})
|
||||
}, [isSiteThemeDark, setCodeColorScheme])
|
||||
});
|
||||
}, [isSiteThemeDark, setCodeColorScheme]);
|
||||
|
||||
const [monospaceFont] = settings.useMonospaceFont()
|
||||
const [monospaceFont] = settings.useMonospaceFont();
|
||||
useEffect(() => {
|
||||
document.body.style.removeProperty("--monospace")
|
||||
document.body.style.removeProperty("--monospace");
|
||||
if (monospaceFont) {
|
||||
document.body.style.setProperty("--monospace", `${monospaceFont}, monospace`)
|
||||
document.body.style.setProperty(
|
||||
"--monospace",
|
||||
`${monospaceFont}, monospace`,
|
||||
);
|
||||
}
|
||||
}, [monospaceFont])
|
||||
}, [monospaceFont]);
|
||||
|
||||
const [codeLineHeight] = settings.useCodeLineHeight()
|
||||
const [codeLineHeight] = settings.useCodeLineHeight();
|
||||
useEffect(() => {
|
||||
document.body.style.removeProperty("--code-line-height")
|
||||
document.body.style.setProperty("--code-line-height", `${codeLineHeight}`)
|
||||
}, [codeLineHeight])
|
||||
document.body.style.removeProperty("--code-line-height");
|
||||
document.body.style.setProperty(
|
||||
"--code-line-height",
|
||||
`${codeLineHeight}`,
|
||||
);
|
||||
}, [codeLineHeight]);
|
||||
|
||||
return <></>
|
||||
return <></>;
|
||||
}
|
||||
|
||||
@@ -1,74 +1,104 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react"
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { SyncIcon } from "@primer/octicons-react"
|
||||
import { SyncIcon } from "@primer/octicons-react";
|
||||
|
||||
import Button from "@/components/Button"
|
||||
import ErrorBoundary from "@/components/ErrorBoundary"
|
||||
import SetPageTitle from "@/components/SetPageTitle"
|
||||
import { RequestFailedError } from "@/lib/api"
|
||||
import Button from "@/components/Button";
|
||||
import ErrorBoundary from "@/components/ErrorBoundary";
|
||||
import SetPageTitle from "@/components/SetPageTitle";
|
||||
import { RequestFailedError } from "@/lib/api";
|
||||
|
||||
type ErrorPageProps = {error: Error, reset: () => void };
|
||||
type ErrorPageProps = { error: Error; reset: () => void };
|
||||
|
||||
function NetworkErrorPage({ error }: ErrorPageProps) {
|
||||
return <>
|
||||
<SetPageTitle title="Error" />
|
||||
<div className="grow" />
|
||||
<main className="max-w-prose p-4 md:mx-auto">
|
||||
<h1 className="py-4 font-semibold text-3xl">We're having some trouble reaching the backend</h1>
|
||||
return (
|
||||
<>
|
||||
<SetPageTitle title="Error" />
|
||||
<div className="grow" />
|
||||
<main className="max-w-prose p-4 md:mx-auto">
|
||||
<h1 className="py-4 font-semibold text-3xl">
|
||||
We're having some trouble reaching the backend
|
||||
</h1>
|
||||
|
||||
<div className="rounded bg-gray-2 p-4 text-gray-11">
|
||||
<code className="font-mono text-sm">{error.toString()}</code>
|
||||
</div>
|
||||
<div className="rounded bg-gray-2 p-4 text-gray-11">
|
||||
<code className="font-mono text-sm">
|
||||
{error.toString()}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
<p className="py-4">
|
||||
If your internet connection is okay, we're probably down for maintenance, and will be back shortly. If this issue persists - <a href="https://discord.gg/sutqNShRRs" className="text-blue-11 hover:underline active:translate-y-px">let us know</a>.
|
||||
</p>
|
||||
<p className="py-4">
|
||||
If your internet connection is okay, we're probably down for
|
||||
maintenance, and will be back shortly. If this issue
|
||||
persists -{" "}
|
||||
<a
|
||||
href="https://discord.gg/sutqNShRRs"
|
||||
className="text-blue-11 hover:underline active:translate-y-px"
|
||||
>
|
||||
let us know
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
|
||||
<ErrorBoundary>
|
||||
<Button onClick={() => window.location.reload()}>
|
||||
<SyncIcon /> Try again
|
||||
</Button>
|
||||
</ErrorBoundary>
|
||||
</main>
|
||||
<div className="grow" />
|
||||
</>
|
||||
<ErrorBoundary>
|
||||
<Button onClick={() => window.location.reload()}>
|
||||
<SyncIcon /> Try again
|
||||
</Button>
|
||||
</ErrorBoundary>
|
||||
</main>
|
||||
<div className="grow" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function UnexpectedErrorPage({ error, reset }: ErrorPageProps) {
|
||||
return <>
|
||||
<SetPageTitle title="Error" />
|
||||
<div className="grow" />
|
||||
<main className="max-w-prose p-4 md:mx-auto">
|
||||
<h1 className="font-semibold text-3xl">Something went wrong</h1>
|
||||
<p className="py-4">
|
||||
An unexpected error occurred rendering this page.
|
||||
</p>
|
||||
<div className="rounded bg-gray-2 p-4 text-gray-11">
|
||||
<code className="font-mono text-sm">{error.toString()}</code>
|
||||
</div>
|
||||
<p className="py-4">
|
||||
If this keeps happening, <a href="https://discord.gg/sutqNShRRs" className="text-blue-11 hover:underline active:translate-y-px">let us know</a>.
|
||||
</p>
|
||||
<ErrorBoundary>
|
||||
<Button onClick={reset}>
|
||||
<SyncIcon /> Try again
|
||||
</Button>
|
||||
</ErrorBoundary>
|
||||
</main>
|
||||
<div className="grow" />
|
||||
</>
|
||||
return (
|
||||
<>
|
||||
<SetPageTitle title="Error" />
|
||||
<div className="grow" />
|
||||
<main className="max-w-prose p-4 md:mx-auto">
|
||||
<h1 className="font-semibold text-3xl">Something went wrong</h1>
|
||||
<p className="py-4">
|
||||
An unexpected error occurred rendering this page.
|
||||
</p>
|
||||
<div className="rounded bg-gray-2 p-4 text-gray-11">
|
||||
<code className="font-mono text-sm">
|
||||
{error.toString()}
|
||||
</code>
|
||||
</div>
|
||||
<p className="py-4">
|
||||
If this keeps happening,{" "}
|
||||
<a
|
||||
href="https://discord.gg/sutqNShRRs"
|
||||
className="text-blue-11 hover:underline active:translate-y-px"
|
||||
>
|
||||
let us know
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
<ErrorBoundary>
|
||||
<Button onClick={reset}>
|
||||
<SyncIcon /> Try again
|
||||
</Button>
|
||||
</ErrorBoundary>
|
||||
</main>
|
||||
<div className="grow" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ErrorPage({ error, reset }: ErrorPageProps) {
|
||||
useEffect(() => {
|
||||
console.error(error)
|
||||
}, [error])
|
||||
console.error(error);
|
||||
}, [error]);
|
||||
|
||||
return error instanceof RequestFailedError ? <NetworkErrorPage error={error} reset={reset} /> : <UnexpectedErrorPage error={error} reset={reset} />
|
||||
return error instanceof RequestFailedError ? (
|
||||
<NetworkErrorPage error={error} reset={reset} />
|
||||
) : (
|
||||
<UnexpectedErrorPage error={error} reset={reset} />
|
||||
);
|
||||
}
|
||||
|
||||
export const metadata = {
|
||||
title: "Error",
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import PlausibleProvider from "next-plausible"
|
||||
import PlausibleProvider from "next-plausible";
|
||||
|
||||
import ThemeProvider from "./ThemeProvider"
|
||||
import ThemeProvider from "./ThemeProvider";
|
||||
|
||||
import "allotment/dist/style.css"
|
||||
import "./globals.scss"
|
||||
import "allotment/dist/style.css";
|
||||
import "./globals.scss";
|
||||
|
||||
export const metadata = {
|
||||
title: {
|
||||
@@ -22,37 +22,43 @@ export const metadata = {
|
||||
],
|
||||
},
|
||||
// set this to avoid "metadata.metadataBase is not set..." warning
|
||||
metadataBase: new URL(process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : `http://localhost:${process.env.PORT || 3000}`),
|
||||
}
|
||||
metadataBase: new URL(
|
||||
process.env.VERCEL_URL
|
||||
? `https://${process.env.VERCEL_URL}`
|
||||
: `http://localhost:${process.env.PORT || 3000}`,
|
||||
),
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <html lang="en" className="dark">
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
return (
|
||||
<html lang="en" className="dark">
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
|
||||
<meta name="theme-color" content="#282e31" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<meta name="darkreader-lock" />
|
||||
<meta name="theme-color" content="#282e31" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<meta name="darkreader-lock" />
|
||||
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
|
||||
<link rel="shortcut icon" href="/purplefrog.svg" />
|
||||
<link rel="apple-touch-icon" href="/purplefrog-bg-180.png" />
|
||||
<link rel="shortcut icon" href="/purplefrog.svg" />
|
||||
<link rel="apple-touch-icon" href="/purplefrog-bg-180.png" />
|
||||
|
||||
<PlausibleProvider
|
||||
domain="decomp.me"
|
||||
customDomain="https://stats.decomp.me"
|
||||
selfHosted={true}
|
||||
trackOutboundLinks={true}
|
||||
/>
|
||||
<ThemeProvider />
|
||||
</head>
|
||||
<body className="flex flex-col bg-gray-1 font-sans text-gray-12 subpixel-antialiased">
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
<PlausibleProvider
|
||||
domain="decomp.me"
|
||||
customDomain="https://stats.decomp.me"
|
||||
selfHosted={true}
|
||||
trackOutboundLinks={true}
|
||||
/>
|
||||
<ThemeProvider />
|
||||
</head>
|
||||
<body className="flex flex-col bg-gray-1 font-sans text-gray-12 subpixel-antialiased">
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,28 +1,31 @@
|
||||
import { ChevronRightIcon } from "@primer/octicons-react"
|
||||
import { ChevronRightIcon } from "@primer/octicons-react";
|
||||
|
||||
import GhostButton from "@/components/GhostButton"
|
||||
import Frog from "@/components/Nav/frog.svg"
|
||||
import GhostButton from "@/components/GhostButton";
|
||||
import Frog from "@/components/Nav/frog.svg";
|
||||
|
||||
export default function NotFound() {
|
||||
return <main className="mx-auto my-16 flex max-w-prose items-center justify-center gap-4 px-4 py-6 text-base leading-normal">
|
||||
<div>
|
||||
<div className="flex items-center justify-center gap-8">
|
||||
<Frog className="size-16 saturate-0" />
|
||||
<h1 className="font-medium text-xl lg:text-3xl">
|
||||
<span className="pr-8 font-normal">404</span>
|
||||
<span className="text-gray-12">frog not found</span>
|
||||
</h1>
|
||||
</div>
|
||||
return (
|
||||
<main className="mx-auto my-16 flex max-w-prose items-center justify-center gap-4 px-4 py-6 text-base leading-normal">
|
||||
<div>
|
||||
<div className="flex items-center justify-center gap-8">
|
||||
<Frog className="size-16 saturate-0" />
|
||||
<h1 className="font-medium text-xl lg:text-3xl">
|
||||
<span className="pr-8 font-normal">404</span>
|
||||
<span className="text-gray-12">frog not found</span>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<p className="py-4 text-gray-11">
|
||||
The page you are looking for is not here. Consider checking the URL.
|
||||
</p>
|
||||
<p className="py-4 text-gray-11">
|
||||
The page you are looking for is not here. Consider checking
|
||||
the URL.
|
||||
</p>
|
||||
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<GhostButton href="/">
|
||||
Back to dashboard <ChevronRightIcon />
|
||||
</GhostButton>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<GhostButton href="/">
|
||||
Back to dashboard <ChevronRightIcon />
|
||||
</GhostButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,61 +1,86 @@
|
||||
import { ImageResponse } from "next/og"
|
||||
import { ImageResponse } from "next/og";
|
||||
|
||||
import { platformIcon, PLATFORMS } from "@/components/PlatformSelect/PlatformIcon"
|
||||
import {
|
||||
platformIcon,
|
||||
PLATFORMS,
|
||||
} from "@/components/PlatformSelect/PlatformIcon";
|
||||
|
||||
const IMAGE_WIDTH_PX = 1200
|
||||
const IMAGE_WIDTH_PX = 1200;
|
||||
|
||||
const IMAGE_HEIGHT_PX = 400
|
||||
const IMAGE_HEIGHT_PX = 400;
|
||||
|
||||
export const runtime = "edge"
|
||||
export const runtime = "edge";
|
||||
|
||||
export default async function HomeOG() {
|
||||
const OpenSansExtraBold = fetch(new URL("/public/fonts/OpenSans-ExtraBold.ttf", import.meta.url)).then(res =>
|
||||
res.arrayBuffer()
|
||||
)
|
||||
const OpenSansExtraBold = fetch(
|
||||
new URL("/public/fonts/OpenSans-ExtraBold.ttf", import.meta.url),
|
||||
).then((res) => res.arrayBuffer());
|
||||
|
||||
const OpenSansSemiBold = fetch(new URL("/public/fonts/OpenSans-SemiBold.ttf", import.meta.url)).then(res =>
|
||||
res.arrayBuffer()
|
||||
)
|
||||
const OpenSansSemiBold = fetch(
|
||||
new URL("/public/fonts/OpenSans-SemiBold.ttf", import.meta.url),
|
||||
).then((res) => res.arrayBuffer());
|
||||
|
||||
const OpenSansBold = fetch(new URL("/public/fonts/OpenSans-Bold.ttf", import.meta.url)).then(res =>
|
||||
res.arrayBuffer()
|
||||
)
|
||||
const OpenSansBold = fetch(
|
||||
new URL("/public/fonts/OpenSans-Bold.ttf", import.meta.url),
|
||||
).then((res) => res.arrayBuffer());
|
||||
|
||||
const statsRes = await fetch("http://decomp.me/api/stats")
|
||||
const stats = await statsRes.json()
|
||||
const iconSize = 160
|
||||
const iconCount = 5
|
||||
const textScale = 4.15
|
||||
const statsRes = await fetch("http://decomp.me/api/stats");
|
||||
const stats = await statsRes.json();
|
||||
const iconSize = 160;
|
||||
const iconCount = 5;
|
||||
const textScale = 4.15;
|
||||
const textSize = {
|
||||
title: textScale,
|
||||
description: 0.6 * textScale,
|
||||
stats: 0.45 * textScale,
|
||||
}
|
||||
};
|
||||
return new ImageResponse(
|
||||
(
|
||||
<div
|
||||
tw="flex flex-col w-full h-full bg-zinc-900 text-slate-50 items-center justify-center">
|
||||
<div tw="absolute flex flex-row items-center h-full opacity-25">
|
||||
{PLATFORMS.map(platform => ({ platform, sort: Math.random() }))
|
||||
.sort((a, b) => a.sort - b.sort)
|
||||
.slice(0, iconCount)
|
||||
.map(({ platform }) => {
|
||||
const Icon = platformIcon(platform)
|
||||
return (<Icon key={platform} width={iconSize} height={iconSize} tw="m-10" />)
|
||||
})}
|
||||
</div>
|
||||
<div tw="w-full h-1/10" />
|
||||
<div tw={`flex w-full justify-center text-[${textSize.title}rem]`} style={{ fontFamily: "OpenSans-ExtraBold" }}>decomp.me</div>
|
||||
<span tw={`flex flex-wrap justify-center text-[${textSize.description}rem] text-center w-3/4`} style={{ fontFamily: "OpenSans-Bold" }}>Collaboratively decompile code in your browser</span>
|
||||
<div tw="w-full h-1/8" />
|
||||
<div tw={`flex justify-between w-full text-[${textSize.stats}rem]`} style={{ fontFamily: "OpenSans-SemiBold" }}>
|
||||
<a tw="ml-10">{stats.scratch_count.toLocaleString()} scratches</a>
|
||||
<a>{stats.profile_count.toLocaleString()} visitors</a>
|
||||
<a>{stats.github_user_count.toLocaleString()} users</a>
|
||||
<a tw="mr-10">{stats.asm_count.toLocaleString()} asm globs</a>
|
||||
</div>
|
||||
<div tw="flex flex-col w-full h-full bg-zinc-900 text-slate-50 items-center justify-center">
|
||||
<div tw="absolute flex flex-row items-center h-full opacity-25">
|
||||
{PLATFORMS.map((platform) => ({
|
||||
platform,
|
||||
sort: Math.random(),
|
||||
}))
|
||||
.sort((a, b) => a.sort - b.sort)
|
||||
.slice(0, iconCount)
|
||||
.map(({ platform }) => {
|
||||
const Icon = platformIcon(platform);
|
||||
return (
|
||||
<Icon
|
||||
key={platform}
|
||||
width={iconSize}
|
||||
height={iconSize}
|
||||
tw="m-10"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
),
|
||||
<div tw="w-full h-1/10" />
|
||||
<div
|
||||
tw={`flex w-full justify-center text-[${textSize.title}rem]`}
|
||||
style={{ fontFamily: "OpenSans-ExtraBold" }}
|
||||
>
|
||||
decomp.me
|
||||
</div>
|
||||
<span
|
||||
tw={`flex flex-wrap justify-center text-[${textSize.description}rem] text-center w-3/4`}
|
||||
style={{ fontFamily: "OpenSans-Bold" }}
|
||||
>
|
||||
Collaboratively decompile code in your browser
|
||||
</span>
|
||||
<div tw="w-full h-1/8" />
|
||||
<div
|
||||
tw={`flex justify-between w-full text-[${textSize.stats}rem]`}
|
||||
style={{ fontFamily: "OpenSans-SemiBold" }}
|
||||
>
|
||||
<a tw="ml-10">
|
||||
{stats.scratch_count.toLocaleString()} scratches
|
||||
</a>
|
||||
<a>{stats.profile_count.toLocaleString()} visitors</a>
|
||||
<a>{stats.github_user_count.toLocaleString()} users</a>
|
||||
<a tw="mr-10">{stats.asm_count.toLocaleString()} asm globs</a>
|
||||
</div>
|
||||
</div>,
|
||||
{
|
||||
width: IMAGE_WIDTH_PX,
|
||||
height: IMAGE_HEIGHT_PX,
|
||||
@@ -63,7 +88,6 @@ export default async function HomeOG() {
|
||||
{
|
||||
name: "OpenSans-ExtraBold",
|
||||
data: await OpenSansExtraBold,
|
||||
|
||||
},
|
||||
{
|
||||
name: "OpenSans-SemiBold",
|
||||
@@ -75,5 +99,5 @@ export default async function HomeOG() {
|
||||
},
|
||||
],
|
||||
},
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,32 +1,37 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import useSWR, { type Middleware, SWRConfig } from "swr"
|
||||
import useSWR, { type Middleware, SWRConfig } from "swr";
|
||||
|
||||
import Scratch from "@/components/Scratch"
|
||||
import useWarnBeforeScratchUnload from "@/components/Scratch/hooks/useWarnBeforeScratchUnload"
|
||||
import SetPageTitle from "@/components/SetPageTitle"
|
||||
import * as api from "@/lib/api"
|
||||
import { scratchUrl } from "@/lib/api/urls"
|
||||
import Scratch from "@/components/Scratch";
|
||||
import useWarnBeforeScratchUnload from "@/components/Scratch/hooks/useWarnBeforeScratchUnload";
|
||||
import SetPageTitle from "@/components/SetPageTitle";
|
||||
import * as api from "@/lib/api";
|
||||
import { scratchUrl } from "@/lib/api/urls";
|
||||
|
||||
function ScratchPageTitle({ scratch }: { scratch: api.Scratch }) {
|
||||
const isSaved = api.useIsScratchSaved(scratch)
|
||||
const isSaved = api.useIsScratchSaved(scratch);
|
||||
|
||||
let title = isSaved ? "" : "(unsaved) "
|
||||
title += scratch.name || scratch.slug
|
||||
let title = isSaved ? "" : "(unsaved) ";
|
||||
title += scratch.name || scratch.slug;
|
||||
|
||||
return <SetPageTitle title={title} />
|
||||
return <SetPageTitle title={title} />;
|
||||
}
|
||||
|
||||
function ScratchEditorInner({ initialScratch, parentScratch, initialCompilation, offline }: Props) {
|
||||
const [scratch, setScratch] = useState(initialScratch)
|
||||
function ScratchEditorInner({
|
||||
initialScratch,
|
||||
parentScratch,
|
||||
initialCompilation,
|
||||
offline,
|
||||
}: Props) {
|
||||
const [scratch, setScratch] = useState(initialScratch);
|
||||
|
||||
useWarnBeforeScratchUnload(scratch)
|
||||
useWarnBeforeScratchUnload(scratch);
|
||||
|
||||
// If the static props scratch changes (i.e. router push / page redirect), reset `scratch`.
|
||||
if (scratchUrl(scratch) !== scratchUrl(initialScratch))
|
||||
setScratch(initialScratch)
|
||||
setScratch(initialScratch);
|
||||
|
||||
// If the server scratch owner changes (i.e. scratch was claimed), update local scratch owner.
|
||||
// You can trigger this by:
|
||||
@@ -34,11 +39,18 @@ function ScratchEditorInner({ initialScratch, parentScratch, initialCompilation,
|
||||
// 2. Creating a new scratch
|
||||
// 3. Logging in
|
||||
// 4. Notice the scratch owner (in the About panel) has changed to your newly-logged-in user
|
||||
const ownerMayChange = !scratch.owner || scratch.owner.is_anonymous
|
||||
const cached = useSWR<api.Scratch>(ownerMayChange && scratchUrl(scratch), api.get)?.data
|
||||
if (ownerMayChange && cached?.owner && !api.isUserEq(scratch.owner, cached?.owner)) {
|
||||
console.info("Scratch owner updated", cached.owner)
|
||||
setScratch(scratch => ({ ...scratch, owner: cached.owner }))
|
||||
const ownerMayChange = !scratch.owner || scratch.owner.is_anonymous;
|
||||
const cached = useSWR<api.Scratch>(
|
||||
ownerMayChange && scratchUrl(scratch),
|
||||
api.get,
|
||||
)?.data;
|
||||
if (
|
||||
ownerMayChange &&
|
||||
cached?.owner &&
|
||||
!api.isUserEq(scratch.owner, cached?.owner)
|
||||
) {
|
||||
console.info("Scratch owner updated", cached.owner);
|
||||
setScratch((scratch) => ({ ...scratch, owner: cached.owner }));
|
||||
}
|
||||
|
||||
// On initial page load, request the latest scratch from the server, and
|
||||
@@ -48,64 +60,68 @@ function ScratchEditorInner({ initialScratch, parentScratch, initialCompilation,
|
||||
// https://github.com/decompme/decomp.me/issues/711
|
||||
useEffect(() => {
|
||||
api.get(scratchUrl(scratch)).then((updatedScratch: api.Scratch) => {
|
||||
const updateTime = new Date(updatedScratch.last_updated)
|
||||
const scratchTime = new Date(scratch.last_updated)
|
||||
const updateTime = new Date(updatedScratch.last_updated);
|
||||
const scratchTime = new Date(scratch.last_updated);
|
||||
|
||||
if (scratchTime < updateTime) {
|
||||
console.info("Client got updated scratch", updatedScratch)
|
||||
setScratch(updatedScratch)
|
||||
console.info("Client got updated scratch", updatedScratch);
|
||||
setScratch(updatedScratch);
|
||||
}
|
||||
})
|
||||
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
});
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return <>
|
||||
<ScratchPageTitle scratch={scratch} />
|
||||
<main className="grow">
|
||||
<Scratch
|
||||
scratch={scratch}
|
||||
parentScratch={parentScratch}
|
||||
initialCompilation={initialCompilation}
|
||||
onChange={partial => {
|
||||
setScratch(scratch => {
|
||||
return { ...scratch, ...partial }
|
||||
})
|
||||
}}
|
||||
offline={offline}
|
||||
/>
|
||||
</main>
|
||||
</>
|
||||
return (
|
||||
<>
|
||||
<ScratchPageTitle scratch={scratch} />
|
||||
<main className="grow">
|
||||
<Scratch
|
||||
scratch={scratch}
|
||||
parentScratch={parentScratch}
|
||||
initialCompilation={initialCompilation}
|
||||
onChange={(partial) => {
|
||||
setScratch((scratch) => {
|
||||
return { ...scratch, ...partial };
|
||||
});
|
||||
}}
|
||||
offline={offline}
|
||||
/>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export interface Props {
|
||||
initialScratch: api.Scratch
|
||||
parentScratch?: api.Scratch
|
||||
initialCompilation?: api.Compilation
|
||||
offline?: boolean
|
||||
initialScratch: api.Scratch;
|
||||
parentScratch?: api.Scratch;
|
||||
initialCompilation?: api.Compilation;
|
||||
offline?: boolean;
|
||||
}
|
||||
|
||||
export default function ScratchEditor(props: Props) {
|
||||
const [offline, setOffline] = useState(false)
|
||||
const [offline, setOffline] = useState(false);
|
||||
|
||||
const offlineMiddleware: Middleware = _useSWRNext => {
|
||||
const offlineMiddleware: Middleware = (_useSWRNext) => {
|
||||
return (key, fetcher, config) => {
|
||||
let swr = _useSWRNext(key, fetcher, config)
|
||||
let swr = _useSWRNext(key, fetcher, config);
|
||||
|
||||
if (swr.error instanceof api.RequestFailedError) {
|
||||
setOffline(true)
|
||||
swr = Object.assign({}, swr, { error: null })
|
||||
setOffline(true);
|
||||
swr = Object.assign({}, swr, { error: null });
|
||||
}
|
||||
|
||||
return swr
|
||||
}
|
||||
}
|
||||
return swr;
|
||||
};
|
||||
};
|
||||
|
||||
const onSuccess = () => {
|
||||
setOffline(false)
|
||||
}
|
||||
setOffline(false);
|
||||
};
|
||||
|
||||
return <>
|
||||
<SWRConfig value={{ use: [offlineMiddleware], onSuccess }}>
|
||||
<ScratchEditorInner {...props} offline={offline} />
|
||||
</SWRConfig>
|
||||
</>
|
||||
return (
|
||||
<>
|
||||
<SWRConfig value={{ use: [offlineMiddleware], onSuccess }}>
|
||||
<ScratchEditorInner {...props} offline={offline} />
|
||||
</SWRConfig>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,46 +1,49 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import LoadingSkeleton from "@/app/scratch/[slug]/loading"
|
||||
import { post } from "@/lib/api/request"
|
||||
import LoadingSkeleton from "@/app/scratch/[slug]/loading";
|
||||
import { post } from "@/lib/api/request";
|
||||
|
||||
export default function Page({ params, searchParams }: {
|
||||
params: { slug: string }
|
||||
searchParams: { token: string }
|
||||
export default function Page({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: { slug: string };
|
||||
searchParams: { token: string };
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const router = useRouter();
|
||||
|
||||
// The POST request must happen on the client so
|
||||
// that the Django session cookie is present.
|
||||
const effectRan = useRef(false)
|
||||
const [error, setError] = useState<Error>(null)
|
||||
const effectRan = useRef(false);
|
||||
const [error, setError] = useState<Error>(null);
|
||||
useEffect(() => {
|
||||
if (!effectRan.current) {
|
||||
post(`/scratch/${params.slug}/claim`, { token: searchParams.token })
|
||||
.then(data => {
|
||||
.then((data) => {
|
||||
if (data.success) {
|
||||
router.replace(`/scratch/${params.slug}`)
|
||||
router.replace(`/scratch/${params.slug}`);
|
||||
} else {
|
||||
throw new Error("Unable to claim scratch")
|
||||
throw new Error("Unable to claim scratch");
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error("Failed to claim scratch", err)
|
||||
setError(err)
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Failed to claim scratch", err);
|
||||
setError(err);
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
effectRan.current = true
|
||||
}
|
||||
}, [params.slug, router, searchParams.token])
|
||||
effectRan.current = true;
|
||||
};
|
||||
}, [params.slug, router, searchParams.token]);
|
||||
|
||||
if (error) {
|
||||
// Rely on error boundary to catch and display error
|
||||
throw error
|
||||
throw error;
|
||||
}
|
||||
return <LoadingSkeleton/>
|
||||
return <LoadingSkeleton />;
|
||||
}
|
||||
|
||||
@@ -1,26 +1,30 @@
|
||||
import { get, bubbleNotFound, ResponseError } from "@/lib/api/request"
|
||||
import type { Scratch, Compilation } from "@/lib/api/types"
|
||||
import { scratchParentUrl, scratchUrl } from "@/lib/api/urls"
|
||||
import { get, bubbleNotFound, ResponseError } from "@/lib/api/request";
|
||||
import type { Scratch, Compilation } from "@/lib/api/types";
|
||||
import { scratchParentUrl, scratchUrl } from "@/lib/api/urls";
|
||||
|
||||
export default async function getScratchDetails(slug: string) {
|
||||
const scratch: Scratch = await get(`/scratch/${slug}`).catch(bubbleNotFound)
|
||||
const scratch: Scratch = await get(`/scratch/${slug}`).catch(
|
||||
bubbleNotFound,
|
||||
);
|
||||
|
||||
let compilation: Compilation | null = null
|
||||
let compilation: Compilation | null = null;
|
||||
try {
|
||||
compilation = await get(`${scratchUrl(scratch)}/compile`)
|
||||
compilation = await get(`${scratchUrl(scratch)}/compile`);
|
||||
} catch (error) {
|
||||
if (error instanceof ResponseError && error.status !== 400) {
|
||||
compilation = null
|
||||
compilation = null;
|
||||
} else {
|
||||
throw error
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const parentScratch: Scratch | null = scratch.parent ? await get(scratchParentUrl(scratch)) : null
|
||||
const parentScratch: Scratch | null = scratch.parent
|
||||
? await get(scratchParentUrl(scratch))
|
||||
: null;
|
||||
|
||||
return {
|
||||
scratch,
|
||||
parentScratch,
|
||||
compilation,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo } from "react"
|
||||
import { useMemo } from "react";
|
||||
|
||||
import Nav from "@/components/Nav"
|
||||
import Nav from "@/components/Nav";
|
||||
|
||||
const CODE = `#include "common.h"
|
||||
|
||||
@@ -27,7 +27,7 @@ void step_game_loop(void) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}`
|
||||
}`;
|
||||
|
||||
const DIFF = ` 0: stwu r1,-0x20(r1)
|
||||
4: mflr r0
|
||||
@@ -61,90 +61,100 @@ const DIFF = ` 0: stwu r1,-0x20(r1)
|
||||
74: ~> lwz r0,0x24(r1)
|
||||
78: mtlr r0
|
||||
7c: addi r1,r1,0x20
|
||||
80: blr`
|
||||
80: blr`;
|
||||
|
||||
function TextSkeleton({ text }: { text: string }) {
|
||||
const lines = useMemo(() => (
|
||||
text
|
||||
.split("\n")
|
||||
.map(line => {
|
||||
const lines = useMemo(
|
||||
() =>
|
||||
text.split("\n").map((line) => {
|
||||
// Convert line into a sequence of [word len, space len] pairs.
|
||||
// e.g. "xxxx xx xxx x" -> [[4, 2], [2, 1], [3, 1], [1, 0]]
|
||||
const pairs = []
|
||||
const pairs = [];
|
||||
|
||||
let state: "word" | "space" = "word"
|
||||
let wordLen = 0
|
||||
let spaceLen = 0
|
||||
let state: "word" | "space" = "word";
|
||||
let wordLen = 0;
|
||||
let spaceLen = 0;
|
||||
for (const char of line) {
|
||||
if (char === " ") {
|
||||
if (state === "word") {
|
||||
pairs.push([wordLen, spaceLen])
|
||||
wordLen = 0
|
||||
spaceLen = 0
|
||||
pairs.push([wordLen, spaceLen]);
|
||||
wordLen = 0;
|
||||
spaceLen = 0;
|
||||
}
|
||||
|
||||
state = "space"
|
||||
spaceLen++
|
||||
} else { // non-space
|
||||
state = "space";
|
||||
spaceLen++;
|
||||
} else {
|
||||
// non-space
|
||||
if (state === "space") {
|
||||
pairs.push([wordLen, spaceLen])
|
||||
wordLen = 0
|
||||
spaceLen = 0
|
||||
pairs.push([wordLen, spaceLen]);
|
||||
wordLen = 0;
|
||||
spaceLen = 0;
|
||||
}
|
||||
|
||||
state = "word"
|
||||
wordLen++
|
||||
state = "word";
|
||||
wordLen++;
|
||||
}
|
||||
}
|
||||
pairs.push([wordLen, spaceLen])
|
||||
pairs.push([wordLen, spaceLen]);
|
||||
|
||||
return pairs.filter(([wordLen, spaceLen]) => wordLen > 0 || spaceLen > 0)
|
||||
})
|
||||
), [text])
|
||||
return pairs.filter(
|
||||
([wordLen, spaceLen]) => wordLen > 0 || spaceLen > 0,
|
||||
);
|
||||
}),
|
||||
[text],
|
||||
);
|
||||
|
||||
return <div className="flex flex-col gap-1">
|
||||
{lines.map((pairs, i) =>
|
||||
<div key={i} className="flex h-5">
|
||||
{pairs.map(([wordLen, spaceLen], j) =>
|
||||
<div
|
||||
key={j}
|
||||
className="h-full bg-gray-6"
|
||||
style={{
|
||||
width: `${wordLen}ch`,
|
||||
marginRight: `${spaceLen}ch`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
{lines.map((pairs, i) => (
|
||||
<div key={i} className="flex h-5">
|
||||
{pairs.map(([wordLen, spaceLen], j) => (
|
||||
<div
|
||||
key={j}
|
||||
className="h-full bg-gray-6"
|
||||
style={{
|
||||
width: `${wordLen}ch`,
|
||||
marginRight: `${spaceLen}ch`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LoadingSkeleton() {
|
||||
return <div className="relative flex size-full animate-pulse flex-col overflow-hidden">
|
||||
<Nav>
|
||||
<div className="ml-1 flex w-full items-center gap-1.5">
|
||||
<div className="size-5 rounded-full bg-gray-6" />
|
||||
<div className="h-5 w-16 bg-gray-6" />
|
||||
<div className="h-5 w-48 bg-gray-6" />
|
||||
return (
|
||||
<div className="relative flex size-full animate-pulse flex-col overflow-hidden">
|
||||
<Nav>
|
||||
<div className="ml-1 flex w-full items-center gap-1.5">
|
||||
<div className="size-5 rounded-full bg-gray-6" />
|
||||
<div className="h-5 w-16 bg-gray-6" />
|
||||
<div className="h-5 w-48 bg-gray-6" />
|
||||
</div>
|
||||
</Nav>
|
||||
<div className="flex grow border-gray-6 border-t">
|
||||
<div className="w-1/2 gap-1 overflow-hidden border-gray-6 border-r p-8">
|
||||
<TextSkeleton text={CODE} />
|
||||
</div>
|
||||
<div className="w-1/2 gap-1 overflow-hidden border-gray-6 border-r p-8">
|
||||
<TextSkeleton text={DIFF} />
|
||||
</div>
|
||||
</div>
|
||||
</Nav>
|
||||
<div className="flex grow border-gray-6 border-t">
|
||||
<div className="w-1/2 gap-1 overflow-hidden border-gray-6 border-r p-8">
|
||||
<TextSkeleton text={CODE} />
|
||||
</div>
|
||||
<div className="w-1/2 gap-1 overflow-hidden border-gray-6 border-r p-8">
|
||||
<TextSkeleton text={DIFF} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<noscript>
|
||||
<div role="status" className="-translate-x-1/2 -translate-y-1/2 absolute top-1/2 left-1/2 font-medium">
|
||||
JavaScript is required to edit scratches
|
||||
</div>
|
||||
</noscript>
|
||||
<span role="status" className="sr-only">
|
||||
Loading editor...
|
||||
</span>
|
||||
</div>
|
||||
<noscript>
|
||||
<div
|
||||
role="status"
|
||||
className="-translate-x-1/2 -translate-y-1/2 absolute top-1/2 left-1/2 font-medium"
|
||||
>
|
||||
JavaScript is required to edit scratches
|
||||
</div>
|
||||
</noscript>
|
||||
<span role="status" className="sr-only">
|
||||
Loading editor...
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,48 +1,68 @@
|
||||
import { ImageResponse } from "next/og"
|
||||
import { ImageResponse } from "next/og";
|
||||
|
||||
import { PlatformIcon } from "@/components/PlatformSelect/PlatformIcon"
|
||||
import { percentToString, calculateScorePercent } from "@/components/ScoreBadge"
|
||||
import { get } from "@/lib/api/request"
|
||||
import type { Preset } from "@/lib/api/types"
|
||||
import { PlatformIcon } from "@/components/PlatformSelect/PlatformIcon";
|
||||
import {
|
||||
percentToString,
|
||||
calculateScorePercent,
|
||||
} from "@/components/ScoreBadge";
|
||||
import { get } from "@/lib/api/request";
|
||||
import type { Preset } from "@/lib/api/types";
|
||||
|
||||
import CheckCircleFillIcon from "./assets/check-circle-fill.svg"
|
||||
import PurpleFrog from "./assets/purplefrog.svg"
|
||||
import RepoForkedIcon from "./assets/repo-forked.svg"
|
||||
import TrophyIcon from "./assets/trophy.svg"
|
||||
import XCircleFillIcon from "./assets/x-circle-fill.svg"
|
||||
import getScratchDetails from "./getScratchDetails"
|
||||
import CheckCircleFillIcon from "./assets/check-circle-fill.svg";
|
||||
import PurpleFrog from "./assets/purplefrog.svg";
|
||||
import RepoForkedIcon from "./assets/repo-forked.svg";
|
||||
import TrophyIcon from "./assets/trophy.svg";
|
||||
import XCircleFillIcon from "./assets/x-circle-fill.svg";
|
||||
import getScratchDetails from "./getScratchDetails";
|
||||
|
||||
const truncateText = (text: string, length: number) => `${text.slice(0, length)}...${text.slice(-length, text.length)}`
|
||||
const truncateText = (text: string, length: number) =>
|
||||
`${text.slice(0, length)}...${text.slice(-length, text.length)}`;
|
||||
|
||||
const IMAGE_WIDTH_PX = 1200
|
||||
const IMAGE_HEIGHT_PX = 400
|
||||
const IMAGE_WIDTH_PX = 1200;
|
||||
const IMAGE_HEIGHT_PX = 400;
|
||||
|
||||
export const runtime = "edge"
|
||||
export const runtime = "edge";
|
||||
|
||||
export default async function ScratchOG({ params }: { params: { slug: string }}) {
|
||||
export default async function ScratchOG({
|
||||
params,
|
||||
}: { params: { slug: string } }) {
|
||||
const { scratch, parentScratch, compilation } = await getScratchDetails(
|
||||
params.slug,
|
||||
);
|
||||
|
||||
const { scratch, parentScratch, compilation } = await getScratchDetails(params.slug)
|
||||
|
||||
const preset: Preset | null = scratch.preset !== null ? await get(`/preset/${scratch.preset}`) : null
|
||||
const scratchName = scratch.name.length > 40 ? truncateText(scratch.name, 18) : scratch.name
|
||||
const preset: Preset | null =
|
||||
scratch.preset !== null ? await get(`/preset/${scratch.preset}`) : null;
|
||||
const scratchName =
|
||||
scratch.name.length > 40
|
||||
? truncateText(scratch.name, 18)
|
||||
: scratch.name;
|
||||
const scratchNameSize =
|
||||
scratchName.length > 32 ? "4xl" :
|
||||
scratchName.length > 24 ? "5xl" : "6xl"
|
||||
scratchName.length > 32
|
||||
? "4xl"
|
||||
: scratchName.length > 24
|
||||
? "5xl"
|
||||
: "6xl";
|
||||
|
||||
const score = compilation?.diff_output?.current_score ?? -1
|
||||
const maxScore = compilation?.diff_output?.max_score ?? -1
|
||||
const score = compilation?.diff_output?.current_score ?? -1;
|
||||
const maxScore = compilation?.diff_output?.max_score ?? -1;
|
||||
|
||||
const percent = scratch.match_override ? 100 : calculateScorePercent(score, maxScore)
|
||||
const doneWidth = Math.floor(percent * IMAGE_WIDTH_PX / 100)
|
||||
const todoWidth = IMAGE_WIDTH_PX - doneWidth
|
||||
const percent = scratch.match_override
|
||||
? 100
|
||||
: calculateScorePercent(score, maxScore);
|
||||
const doneWidth = Math.floor((percent * IMAGE_WIDTH_PX) / 100);
|
||||
const todoWidth = IMAGE_WIDTH_PX - doneWidth;
|
||||
|
||||
return new ImageResponse(
|
||||
<div tw="flex flex-col justify-between w-full h-full bg-zinc-800 text-slate-50 text-5xl">
|
||||
<div tw="flex flex-col">
|
||||
<div tw="flex flex-row justify-between ml-15 mr-15 mt-5">
|
||||
<div tw="flex flex-col justify-center">
|
||||
<div tw="flex text-slate-300">{scratch.owner?.username ?? "No Owner"}</div>
|
||||
<div tw={`flex text-${scratchNameSize}`}>{scratchName}</div>
|
||||
<div tw="flex text-slate-300">
|
||||
{scratch.owner?.username ?? "No Owner"}
|
||||
</div>
|
||||
<div tw={`flex text-${scratchNameSize}`}>
|
||||
{scratchName}
|
||||
</div>
|
||||
</div>
|
||||
<div tw="flex bg-zinc-700 rounded-2xl">
|
||||
<div tw="flex m-3">
|
||||
@@ -53,13 +73,14 @@ export default async function ScratchOG({ params }: { params: { slug: string }})
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div tw="flex mt-3 ml-15 mr-15 text-slate-300">{preset?.name || "Custom Preset"}</div>
|
||||
<div tw="flex mt-3 ml-15 mr-15 text-slate-300">
|
||||
{preset?.name || "Custom Preset"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div tw="flex justify-between mt-5 ml-15 mr-15">
|
||||
<div tw="flex">
|
||||
{score === -1
|
||||
?
|
||||
{score === -1 ? (
|
||||
<div tw="flex">
|
||||
<div tw="flex flex-col justify-around">
|
||||
<XCircleFillIcon width={48} height={48} />
|
||||
@@ -68,42 +89,44 @@ export default async function ScratchOG({ params }: { params: { slug: string }})
|
||||
<div tw="flex">No Score</div>
|
||||
</div>
|
||||
</div>
|
||||
: score === 0 || scratch.match_override
|
||||
?
|
||||
<div tw="flex">
|
||||
<div tw="flex flex-col justify-around">
|
||||
<CheckCircleFillIcon width={48} height={48} />
|
||||
</div>
|
||||
<div tw="flex flex-col items-center ml-5">
|
||||
<div tw="flex">Matched</div>
|
||||
{scratch.match_override &&
|
||||
<div tw="flex text-4xl text-slate-300">(Override)</div>
|
||||
}
|
||||
</div>
|
||||
) : score === 0 || scratch.match_override ? (
|
||||
<div tw="flex">
|
||||
<div tw="flex flex-col justify-around">
|
||||
<CheckCircleFillIcon width={48} height={48} />
|
||||
</div>
|
||||
:
|
||||
<div tw="flex">
|
||||
<div tw="flex flex-col justify-around">
|
||||
<TrophyIcon width={48} height={48} />
|
||||
</div>
|
||||
<div tw="flex flex-col justify-around ml-5">
|
||||
<div tw="flex">
|
||||
{score} ({percentToString(percent)})
|
||||
<div tw="flex flex-col items-center ml-5">
|
||||
<div tw="flex">Matched</div>
|
||||
{scratch.match_override && (
|
||||
<div tw="flex text-4xl text-slate-300">
|
||||
(Override)
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div tw="flex">
|
||||
<div tw="flex flex-col justify-around">
|
||||
<TrophyIcon width={48} height={48} />
|
||||
</div>
|
||||
<div tw="flex flex-col justify-around ml-5">
|
||||
<div tw="flex">
|
||||
{score} ({percentToString(percent)})
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{parentScratch &&
|
||||
<div tw="flex ml-10">
|
||||
<div tw="flex flex-col justify-around">
|
||||
<RepoForkedIcon width={48} height={48} />
|
||||
{parentScratch && (
|
||||
<div tw="flex ml-10">
|
||||
<div tw="flex flex-col justify-around">
|
||||
<RepoForkedIcon width={48} height={48} />
|
||||
</div>
|
||||
<div tw="flex flex-col justify-around ml-5">
|
||||
{parentScratch.owner?.username ??
|
||||
"Anonymous User"}
|
||||
</div>
|
||||
</div>
|
||||
<div tw="flex flex-col justify-around ml-5">
|
||||
{parentScratch.owner?.username ?? "Anonymous User"}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div tw="flex flex-col justify-around ml-15">
|
||||
@@ -120,5 +143,5 @@ export default async function ScratchOG({ params }: { params: { slug: string }})
|
||||
width: IMAGE_WIDTH_PX,
|
||||
height: IMAGE_HEIGHT_PX,
|
||||
},
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import type { Metadata, ResolvingMetadata } from "next"
|
||||
import type { Metadata, ResolvingMetadata } from "next";
|
||||
|
||||
import getScratchDetails from "./getScratchDetails"
|
||||
import ScratchEditor from "./ScratchEditor"
|
||||
import getScratchDetails from "./getScratchDetails";
|
||||
import ScratchEditor from "./ScratchEditor";
|
||||
|
||||
export async function generateMetadata({ params }: { params: { slug: string }}, parent: ResolvingMetadata):Promise<Metadata> {
|
||||
const { scratch } = await getScratchDetails(params.slug)
|
||||
const parentData = await parent
|
||||
export async function generateMetadata(
|
||||
{ params }: { params: { slug: string } },
|
||||
parent: ResolvingMetadata,
|
||||
): Promise<Metadata> {
|
||||
const { scratch } = await getScratchDetails(params.slug);
|
||||
const parentData = await parent;
|
||||
|
||||
return {
|
||||
title: scratch.name,
|
||||
@@ -19,17 +22,20 @@ export async function generateMetadata({ params }: { params: { slug: string }},
|
||||
height: 400,
|
||||
},
|
||||
],
|
||||
|
||||
},
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default async function Page({ params }: { params: { slug: string }}) {
|
||||
const { scratch, parentScratch, compilation } = await getScratchDetails(params.slug)
|
||||
export default async function Page({ params }: { params: { slug: string } }) {
|
||||
const { scratch, parentScratch, compilation } = await getScratchDetails(
|
||||
params.slug,
|
||||
);
|
||||
|
||||
return <ScratchEditor
|
||||
initialScratch={scratch}
|
||||
parentScratch={parentScratch}
|
||||
initialCompilation={compilation}
|
||||
/>
|
||||
return (
|
||||
<ScratchEditor
|
||||
initialScratch={scratch}
|
||||
parentScratch={parentScratch}
|
||||
initialCompilation={compilation}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,85 +1,98 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import { type ReactNode, useState, useCallback } from "react"
|
||||
import { type ReactNode, useState, useCallback } from "react";
|
||||
|
||||
import classNames from "classnames"
|
||||
import { motion, AnimatePresence } from "framer-motion"
|
||||
import { useLayer, Arrow } from "react-laag"
|
||||
import classNames from "classnames";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { useLayer, Arrow } from "react-laag";
|
||||
|
||||
import styles from "./AsyncButton.module.scss"
|
||||
import Button, { type Props as ButtonProps } from "./Button"
|
||||
import LoadingSpinner from "./loading.svg"
|
||||
import styles from "./AsyncButton.module.scss";
|
||||
import Button, { type Props as ButtonProps } from "./Button";
|
||||
import LoadingSpinner from "./loading.svg";
|
||||
|
||||
export interface Props extends ButtonProps {
|
||||
onClick: () => Promise<unknown>
|
||||
forceLoading?: boolean
|
||||
errorPlacement?: import("react-laag/dist/PlacementType").PlacementType
|
||||
children: ReactNode
|
||||
onClick: () => Promise<unknown>;
|
||||
forceLoading?: boolean;
|
||||
errorPlacement?: import("react-laag/dist/PlacementType").PlacementType;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export default function AsyncButton(props: Props) {
|
||||
const [isAwaitingPromise, setIsAwaitingPromise] = useState(false)
|
||||
const isLoading = isAwaitingPromise || props.forceLoading
|
||||
const [errorMessage, setErrorMessage] = useState("")
|
||||
const clickHandler = props.onClick
|
||||
const [isAwaitingPromise, setIsAwaitingPromise] = useState(false);
|
||||
const isLoading = isAwaitingPromise || props.forceLoading;
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const clickHandler = props.onClick;
|
||||
const onClick = useCallback(() => {
|
||||
if (!isLoading) {
|
||||
setIsAwaitingPromise(true)
|
||||
setErrorMessage("")
|
||||
setIsAwaitingPromise(true);
|
||||
setErrorMessage("");
|
||||
|
||||
const promise = clickHandler()
|
||||
const promise = clickHandler();
|
||||
|
||||
if (promise instanceof Promise) {
|
||||
promise.catch(error => {
|
||||
console.error("AsyncButton caught error", error)
|
||||
setErrorMessage(error.message || error.toString())
|
||||
}).finally(() => {
|
||||
setIsAwaitingPromise(false)
|
||||
})
|
||||
promise
|
||||
.catch((error) => {
|
||||
console.error("AsyncButton caught error", error);
|
||||
setErrorMessage(error.message || error.toString());
|
||||
})
|
||||
.finally(() => {
|
||||
setIsAwaitingPromise(false);
|
||||
});
|
||||
} else {
|
||||
console.error("AsyncButton onClick() must return a promise, but instead it returned", promise)
|
||||
setIsAwaitingPromise(false)
|
||||
console.error(
|
||||
"AsyncButton onClick() must return a promise, but instead it returned",
|
||||
promise,
|
||||
);
|
||||
setIsAwaitingPromise(false);
|
||||
}
|
||||
}
|
||||
}, [isLoading, clickHandler])
|
||||
}, [isLoading, clickHandler]);
|
||||
const { triggerProps, layerProps, arrowProps, renderLayer } = useLayer({
|
||||
isOpen: errorMessage !== "",
|
||||
onOutsideClick: () => setErrorMessage(""),
|
||||
placement: props.errorPlacement ?? "top-center",
|
||||
auto: true,
|
||||
triggerOffset: 8,
|
||||
})
|
||||
});
|
||||
|
||||
return <Button
|
||||
{...props}
|
||||
className={classNames(styles.asyncBtn, props.className, {
|
||||
[styles.isLoading]: isLoading,
|
||||
})}
|
||||
onClick={onClick}
|
||||
{...triggerProps}
|
||||
>
|
||||
<div className={styles.label}>
|
||||
{props.children}
|
||||
</div>
|
||||
return (
|
||||
<Button
|
||||
{...props}
|
||||
className={classNames(styles.asyncBtn, props.className, {
|
||||
[styles.isLoading]: isLoading,
|
||||
})}
|
||||
onClick={onClick}
|
||||
{...triggerProps}
|
||||
>
|
||||
<div className={styles.label}>{props.children}</div>
|
||||
|
||||
<div className={styles.loading}>
|
||||
<LoadingSpinner width="24px" />
|
||||
</div>
|
||||
<div className={styles.loading}>
|
||||
<LoadingSpinner width="24px" />
|
||||
</div>
|
||||
|
||||
{renderLayer(
|
||||
<AnimatePresence>
|
||||
{errorMessage && <motion.div
|
||||
className={styles.errorPopup}
|
||||
initial={{ scaleX: 0.7, scaleY: 0, opacity: 0 }}
|
||||
animate={{ scaleX: 1, scaleY: 1, opacity: 1 }}
|
||||
exit={{ scaleX: 0.7, scaleY: 0, opacity: 0 }}
|
||||
transition={{ type: "spring", duration: 0.1 }}
|
||||
{...layerProps}
|
||||
>
|
||||
<pre>{errorMessage}</pre>
|
||||
<Arrow onPointerEnterCapture={undefined} onPointerLeaveCapture={undefined} size={12} backgroundColor="#bb4444" {...arrowProps} />
|
||||
</motion.div>}
|
||||
</AnimatePresence>
|
||||
)}
|
||||
</Button>
|
||||
{renderLayer(
|
||||
<AnimatePresence>
|
||||
{errorMessage && (
|
||||
<motion.div
|
||||
className={styles.errorPopup}
|
||||
initial={{ scaleX: 0.7, scaleY: 0, opacity: 0 }}
|
||||
animate={{ scaleX: 1, scaleY: 1, opacity: 1 }}
|
||||
exit={{ scaleX: 0.7, scaleY: 0, opacity: 0 }}
|
||||
transition={{ type: "spring", duration: 0.1 }}
|
||||
{...layerProps}
|
||||
>
|
||||
<pre>{errorMessage}</pre>
|
||||
<Arrow
|
||||
onPointerEnterCapture={undefined}
|
||||
onPointerLeaveCapture={undefined}
|
||||
size={12}
|
||||
backgroundColor="#bb4444"
|
||||
{...arrowProps}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>,
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,38 +1,49 @@
|
||||
import type { ReactNode } from "react"
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import Link from "next/link"
|
||||
import Link from "next/link";
|
||||
|
||||
import classNames from "classnames"
|
||||
import classNames from "classnames";
|
||||
|
||||
import styles from "./Breadcrumbs.module.scss"
|
||||
import styles from "./Breadcrumbs.module.scss";
|
||||
|
||||
export interface Props {
|
||||
pages: {
|
||||
label: ReactNode
|
||||
href?: string
|
||||
}[]
|
||||
className?: string
|
||||
label: ReactNode;
|
||||
href?: string;
|
||||
}[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function Breadcrumbs({ pages, className }: Props) {
|
||||
// https://www.w3.org/TR/wai-aria-practices/examples/breadcrumb/index.html
|
||||
return (
|
||||
<nav aria-label="Breadcrumb" className={classNames(styles.breadcrumbs, className)}>
|
||||
<nav
|
||||
aria-label="Breadcrumb"
|
||||
className={classNames(styles.breadcrumbs, className)}
|
||||
>
|
||||
<ol>
|
||||
{pages.map((page, index) => {
|
||||
const isLast = index === pages.length - 1
|
||||
const isLast = index === pages.length - 1;
|
||||
|
||||
const a = <a aria-current={isLast ? "page" : undefined}>
|
||||
{page.label}
|
||||
</a>
|
||||
const a = (
|
||||
<a aria-current={isLast ? "page" : undefined}>
|
||||
{page.label}
|
||||
</a>
|
||||
);
|
||||
|
||||
return (
|
||||
<li key={page.href || index}>
|
||||
{page.href ? <Link href={page.href} legacyBehavior>{a}</Link> : page.label}
|
||||
{page.href ? (
|
||||
<Link href={page.href} legacyBehavior>
|
||||
{a}
|
||||
</Link>
|
||||
) : (
|
||||
page.label
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</nav>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,62 +1,70 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import { type ForwardedRef, forwardRef } from "react"
|
||||
import { type ForwardedRef, forwardRef } from "react";
|
||||
|
||||
import Link from "next/link"
|
||||
import Link from "next/link";
|
||||
|
||||
import classNames from "classnames"
|
||||
import classNames from "classnames";
|
||||
|
||||
import styles from "./Button.module.scss"
|
||||
import styles from "./Button.module.scss";
|
||||
|
||||
const Button = forwardRef(function Button({
|
||||
children,
|
||||
onClick,
|
||||
href,
|
||||
className,
|
||||
disabled,
|
||||
primary,
|
||||
danger,
|
||||
title,
|
||||
}: Props, ref: ForwardedRef<HTMLButtonElement>) {
|
||||
const cn = classNames(className, styles.btn, "px-2.5 py-1.5 rounded text-sm active:translate-y-px", {
|
||||
[styles.primary]: primary,
|
||||
[styles.danger]: danger,
|
||||
})
|
||||
const Button = forwardRef(function Button(
|
||||
{
|
||||
children,
|
||||
onClick,
|
||||
href,
|
||||
className,
|
||||
disabled,
|
||||
primary,
|
||||
danger,
|
||||
title,
|
||||
}: Props,
|
||||
ref: ForwardedRef<HTMLButtonElement>,
|
||||
) {
|
||||
const cn = classNames(
|
||||
className,
|
||||
styles.btn,
|
||||
"px-2.5 py-1.5 rounded text-sm active:translate-y-px",
|
||||
{
|
||||
[styles.primary]: primary,
|
||||
[styles.danger]: danger,
|
||||
},
|
||||
);
|
||||
|
||||
if (href) {
|
||||
return <Link
|
||||
className={cn}
|
||||
title={title}
|
||||
href={href}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
return (
|
||||
<Link className={cn} title={title} href={href}>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return <button
|
||||
ref={ref}
|
||||
className={cn}
|
||||
onClick={event => {
|
||||
if (!disabled && onClick) {
|
||||
onClick(event)
|
||||
}
|
||||
}}
|
||||
disabled={disabled}
|
||||
title={title}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
})
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
className={cn}
|
||||
onClick={(event) => {
|
||||
if (!disabled && onClick) {
|
||||
onClick(event);
|
||||
}
|
||||
}}
|
||||
disabled={disabled}
|
||||
title={title}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
export type Props = {
|
||||
children?: React.ReactNode
|
||||
onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void
|
||||
href?: string
|
||||
className?: string
|
||||
disabled?: boolean
|
||||
primary?: boolean
|
||||
danger?: boolean
|
||||
title?: string
|
||||
}
|
||||
children?: React.ReactNode;
|
||||
onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
href?: string;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
primary?: boolean;
|
||||
danger?: boolean;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
export default Button
|
||||
export default Button;
|
||||
|
||||
@@ -1,73 +1,87 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import isDarkColor from "is-dark-color"
|
||||
import { HexColorPicker, HexColorInput } from "react-colorful"
|
||||
import isDarkColor from "is-dark-color";
|
||||
import { HexColorPicker, HexColorInput } from "react-colorful";
|
||||
|
||||
import { COLOR_NAMES, type ColorScheme, getColors } from "@/lib/codemirror/color-scheme"
|
||||
import {
|
||||
COLOR_NAMES,
|
||||
type ColorScheme,
|
||||
getColors,
|
||||
} from "@/lib/codemirror/color-scheme";
|
||||
|
||||
import styles from "./ColorSchemeEditor.module.scss"
|
||||
import ErrorBoundary from "./ErrorBoundary"
|
||||
import styles from "./ColorSchemeEditor.module.scss";
|
||||
import ErrorBoundary from "./ErrorBoundary";
|
||||
|
||||
function Color({ color, name, onChange }: { color: string, name: string, onChange: (color: string) => void }) {
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [isDark, setIsDark] = useState(false)
|
||||
function Color({
|
||||
color,
|
||||
name,
|
||||
onChange,
|
||||
}: { color: string; name: string; onChange: (color: string) => void }) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [isDark, setIsDark] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
setIsDark(isDarkColor(color))
|
||||
setIsDark(isDarkColor(color));
|
||||
} catch (error) {
|
||||
// Ignore
|
||||
}
|
||||
}, [color])
|
||||
}, [color]);
|
||||
|
||||
return <li
|
||||
aria-label={name}
|
||||
className={styles.color}
|
||||
tabIndex={0}
|
||||
onFocus={() => setIsEditing(true)}
|
||||
onClick={() => setIsEditing(true)}
|
||||
onBlur={() => setIsEditing(false)}
|
||||
data-active={isEditing}
|
||||
style={{
|
||||
color: isDark ? "white" : "black",
|
||||
backgroundColor: color,
|
||||
}}
|
||||
>
|
||||
{!isEditing && <label>{name}</label>}
|
||||
{isEditing && <>
|
||||
<HexColorPicker color={color} onChange={onChange} />
|
||||
<HexColorInput
|
||||
autoFocus={true}
|
||||
onFocus={evt => evt.target.select()}
|
||||
color={color}
|
||||
onChange={onChange}
|
||||
prefixed
|
||||
/>
|
||||
</>}
|
||||
</li>
|
||||
return (
|
||||
<li
|
||||
aria-label={name}
|
||||
className={styles.color}
|
||||
tabIndex={0}
|
||||
onFocus={() => setIsEditing(true)}
|
||||
onClick={() => setIsEditing(true)}
|
||||
onBlur={() => setIsEditing(false)}
|
||||
data-active={isEditing}
|
||||
style={{
|
||||
color: isDark ? "white" : "black",
|
||||
backgroundColor: color,
|
||||
}}
|
||||
>
|
||||
{!isEditing && <label>{name}</label>}
|
||||
{isEditing && (
|
||||
<>
|
||||
<HexColorPicker color={color} onChange={onChange} />
|
||||
<HexColorInput
|
||||
autoFocus={true}
|
||||
onFocus={(evt) => evt.target.select()}
|
||||
color={color}
|
||||
onChange={onChange}
|
||||
prefixed
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export interface Props {
|
||||
scheme: ColorScheme
|
||||
onChange: (scheme: ColorScheme) => void
|
||||
scheme: ColorScheme;
|
||||
onChange: (scheme: ColorScheme) => void;
|
||||
}
|
||||
|
||||
export default function ColorSchemeEditor({ scheme, onChange }: Props) {
|
||||
const colors = getColors(scheme)
|
||||
const colors = getColors(scheme);
|
||||
|
||||
const els = []
|
||||
const els = [];
|
||||
for (const [key, name] of Object.entries(COLOR_NAMES)) {
|
||||
els.push(<Color
|
||||
key={key}
|
||||
color={colors[key as keyof typeof colors]}
|
||||
name={name}
|
||||
onChange={color => onChange({ ...colors, [key]: color })}
|
||||
/>)
|
||||
els.push(
|
||||
<Color
|
||||
key={key}
|
||||
color={colors[key as keyof typeof colors]}
|
||||
name={name}
|
||||
onChange={(color) => onChange({ ...colors, [key]: color })}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
return <ErrorBoundary onError={() => onChange("Frog Dark")}>
|
||||
<ul className={styles.container}>
|
||||
{els}
|
||||
</ul>
|
||||
</ErrorBoundary>
|
||||
return (
|
||||
<ErrorBoundary onError={() => onChange("Frog Dark")}>
|
||||
<ul className={styles.container}>{els}</ul>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,40 +1,53 @@
|
||||
import { type ColorScheme, DARK_THEMES, getColors, LIGHT_THEMES } from "@/lib/codemirror/color-scheme"
|
||||
import { useIsSiteThemeDark } from "@/lib/settings"
|
||||
import {
|
||||
type ColorScheme,
|
||||
DARK_THEMES,
|
||||
getColors,
|
||||
LIGHT_THEMES,
|
||||
} from "@/lib/codemirror/color-scheme";
|
||||
import { useIsSiteThemeDark } from "@/lib/settings";
|
||||
|
||||
import ColorSchemeEditor from "./ColorSchemeEditor"
|
||||
import styles from "./ColorSchemePicker.module.scss"
|
||||
import ColorSchemeEditor from "./ColorSchemeEditor";
|
||||
import styles from "./ColorSchemePicker.module.scss";
|
||||
|
||||
export interface Props {
|
||||
scheme: ColorScheme
|
||||
onChange: (scheme: ColorScheme) => void
|
||||
scheme: ColorScheme;
|
||||
onChange: (scheme: ColorScheme) => void;
|
||||
}
|
||||
|
||||
export default function ColorSchemePicker({ scheme, onChange }: Props) {
|
||||
const themes = useIsSiteThemeDark() ? DARK_THEMES : LIGHT_THEMES
|
||||
const themes = useIsSiteThemeDark() ? DARK_THEMES : LIGHT_THEMES;
|
||||
|
||||
return <div className={styles.container}>
|
||||
<ul className={styles.presets}>
|
||||
{themes.map(theme => {
|
||||
return <li key={theme}>
|
||||
<button
|
||||
className={styles.box}
|
||||
onClick={() => onChange(theme)}
|
||||
data-active={scheme === theme}
|
||||
>
|
||||
{theme}
|
||||
<div className={styles.colors}>
|
||||
{Object.entries(getColors(theme)).map(([key, color]) => (
|
||||
<div
|
||||
key={key}
|
||||
style={{ backgroundColor: color }}
|
||||
/>)
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
})}
|
||||
</ul>
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<ul className={styles.presets}>
|
||||
{themes.map((theme) => {
|
||||
return (
|
||||
<li key={theme}>
|
||||
<button
|
||||
className={styles.box}
|
||||
onClick={() => onChange(theme)}
|
||||
data-active={scheme === theme}
|
||||
>
|
||||
{theme}
|
||||
<div className={styles.colors}>
|
||||
{Object.entries(getColors(theme)).map(
|
||||
([key, color]) => (
|
||||
<div
|
||||
key={key}
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
<ColorSchemeEditor scheme={scheme} onChange={onChange} />
|
||||
</div>
|
||||
<ColorSchemeEditor scheme={scheme} onChange={onChange} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,102 +1,107 @@
|
||||
import type { ReactElement } from "react"
|
||||
import type { ReactElement } from "react";
|
||||
|
||||
import { Allotment } from "allotment"
|
||||
import { Allotment } from "allotment";
|
||||
|
||||
import Tabs, { type Tab } from "./Tabs"
|
||||
import Tabs, { type Tab } from "./Tabs";
|
||||
|
||||
export interface HorizontalSplit {
|
||||
key: number
|
||||
kind: "horizontal"
|
||||
size: number
|
||||
children: Layout[]
|
||||
key: number;
|
||||
kind: "horizontal";
|
||||
size: number;
|
||||
children: Layout[];
|
||||
}
|
||||
|
||||
export interface VerticalSplit {
|
||||
key: number
|
||||
kind: "vertical"
|
||||
size: number
|
||||
children: Layout[]
|
||||
key: number;
|
||||
kind: "vertical";
|
||||
size: number;
|
||||
children: Layout[];
|
||||
}
|
||||
|
||||
export interface Pane {
|
||||
key: number
|
||||
kind: "pane"
|
||||
size: number
|
||||
activeTab: string
|
||||
tabs: string[]
|
||||
key: number;
|
||||
kind: "pane";
|
||||
size: number;
|
||||
activeTab: string;
|
||||
tabs: string[];
|
||||
}
|
||||
|
||||
export type Layout = HorizontalSplit | VerticalSplit | Pane
|
||||
export type Layout = HorizontalSplit | VerticalSplit | Pane;
|
||||
|
||||
export function visitLayout(layout: Layout, visitor: (layout: Layout) => void) {
|
||||
visitor(layout)
|
||||
visitor(layout);
|
||||
|
||||
if (layout.kind === "horizontal" || layout.kind === "vertical") {
|
||||
for (const child of layout.children) {
|
||||
visitLayout(child, visitor)
|
||||
visitLayout(child, visitor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function activateTabInLayout(layout: Layout, tab: string) {
|
||||
visitLayout(layout, node => {
|
||||
visitLayout(layout, (node) => {
|
||||
if (node.kind === "pane") {
|
||||
if (node.tabs.includes(tab)) {
|
||||
node.activeTab = tab
|
||||
node.activeTab = tab;
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
export interface Props {
|
||||
layout: Layout
|
||||
onChange: (layout: Layout) => void
|
||||
renderTab: (id: string) => ReactElement<typeof Tab>
|
||||
layout: Layout;
|
||||
onChange: (layout: Layout) => void;
|
||||
renderTab: (id: string) => ReactElement<typeof Tab>;
|
||||
}
|
||||
|
||||
export default function CustomLayout({ renderTab, layout, onChange }: Props) {
|
||||
if (layout.kind === "pane") {
|
||||
const els = []
|
||||
const els = [];
|
||||
|
||||
for (const id of layout.tabs) {
|
||||
els.push(renderTab(id))
|
||||
els.push(renderTab(id));
|
||||
}
|
||||
|
||||
return <Tabs
|
||||
activeTab={layout.activeTab}
|
||||
onChange={activeTab => onChange({ ...layout, activeTab })}
|
||||
>
|
||||
{els}
|
||||
</Tabs>
|
||||
return (
|
||||
<Tabs
|
||||
activeTab={layout.activeTab}
|
||||
onChange={(activeTab) => onChange({ ...layout, activeTab })}
|
||||
>
|
||||
{els}
|
||||
</Tabs>
|
||||
);
|
||||
} else {
|
||||
const els = []
|
||||
const minCollapsedHeight = 37
|
||||
const els = [];
|
||||
const minCollapsedHeight = 37;
|
||||
for (let index = 0; index < layout.children.length; index++) {
|
||||
const child = layout.children[index]
|
||||
const child = layout.children[index];
|
||||
|
||||
const setChild = (newChild: Layout) => {
|
||||
const clone = JSON.parse(JSON.stringify(layout)) as HorizontalSplit | VerticalSplit
|
||||
clone.children[index] = newChild
|
||||
onChange(clone)
|
||||
}
|
||||
const clone = JSON.parse(JSON.stringify(layout)) as
|
||||
| HorizontalSplit
|
||||
| VerticalSplit;
|
||||
clone.children[index] = newChild;
|
||||
onChange(clone);
|
||||
};
|
||||
|
||||
els.push(<Allotment.Pane
|
||||
key={child.key}
|
||||
minSize={minCollapsedHeight}
|
||||
>
|
||||
<CustomLayout
|
||||
renderTab={renderTab}
|
||||
layout={child}
|
||||
onChange={setChild}
|
||||
/>
|
||||
</Allotment.Pane>)
|
||||
els.push(
|
||||
<Allotment.Pane key={child.key} minSize={minCollapsedHeight}>
|
||||
<CustomLayout
|
||||
renderTab={renderTab}
|
||||
layout={child}
|
||||
onChange={setChild}
|
||||
/>
|
||||
</Allotment.Pane>,
|
||||
);
|
||||
}
|
||||
|
||||
return <Allotment
|
||||
key={layout.kind} // Force remount when layout.kind changes
|
||||
vertical={layout.kind === "vertical"}
|
||||
>
|
||||
{els}
|
||||
</Allotment>
|
||||
return (
|
||||
<Allotment
|
||||
key={layout.kind} // Force remount when layout.kind changes
|
||||
vertical={layout.kind === "vertical"}
|
||||
>
|
||||
{els}
|
||||
</Allotment>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
import { useMemo, useRef, useState } from "react"
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
|
||||
import { ChevronDownIcon, ChevronUpIcon } from "@primer/octicons-react"
|
||||
import { Allotment, type AllotmentHandle } from "allotment"
|
||||
import Ansi from "ansi-to-react"
|
||||
import { ChevronDownIcon, ChevronUpIcon } from "@primer/octicons-react";
|
||||
import { Allotment, type AllotmentHandle } from "allotment";
|
||||
import Ansi from "ansi-to-react";
|
||||
|
||||
import type * as api from "@/lib/api"
|
||||
import { interdiff } from "@/lib/interdiff"
|
||||
import { ThreeWayDiffBase, useThreeWayDiffBase } from "@/lib/settings"
|
||||
import type * as api from "@/lib/api";
|
||||
import { interdiff } from "@/lib/interdiff";
|
||||
import { ThreeWayDiffBase, useThreeWayDiffBase } from "@/lib/settings";
|
||||
|
||||
import GhostButton from "../GhostButton"
|
||||
import GhostButton from "../GhostButton";
|
||||
|
||||
import Diff from "./Diff"
|
||||
import Diff from "./Diff";
|
||||
|
||||
function getProblemState(compilation: api.Compilation): ProblemState {
|
||||
if (!compilation.success) {
|
||||
return ProblemState.ERRORS
|
||||
return ProblemState.ERRORS;
|
||||
} else if (compilation.compiler_output) {
|
||||
return ProblemState.WARNINGS
|
||||
return ProblemState.WARNINGS;
|
||||
} else {
|
||||
return ProblemState.NO_PROBLEMS
|
||||
return ProblemState.NO_PROBLEMS;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,121 +29,144 @@ export enum ProblemState {
|
||||
}
|
||||
|
||||
export type PerSaveObj = {
|
||||
diff?: api.DiffOutput
|
||||
}
|
||||
diff?: api.DiffOutput;
|
||||
};
|
||||
|
||||
export type Props = {
|
||||
scratch: api.Scratch
|
||||
compilation: api.Compilation
|
||||
isCompiling?: boolean
|
||||
isCompilationOld?: boolean
|
||||
selectedSourceLine: number | null
|
||||
perSaveObj: PerSaveObj
|
||||
}
|
||||
scratch: api.Scratch;
|
||||
compilation: api.Compilation;
|
||||
isCompiling?: boolean;
|
||||
isCompilationOld?: boolean;
|
||||
selectedSourceLine: number | null;
|
||||
perSaveObj: PerSaveObj;
|
||||
};
|
||||
|
||||
export default function CompilationPanel({ scratch, compilation, isCompiling, isCompilationOld, selectedSourceLine, perSaveObj }: Props) {
|
||||
const usedCompilationRef = useRef<api.Compilation | null>(null)
|
||||
const problemState = getProblemState(compilation)
|
||||
const [threeWayDiffBase] = useThreeWayDiffBase()
|
||||
const [threeWayDiffEnabled, setThreeWayDiffEnabled] = useState(false)
|
||||
const prevCompilation = usedCompilationRef.current
|
||||
export default function CompilationPanel({
|
||||
scratch,
|
||||
compilation,
|
||||
isCompiling,
|
||||
isCompilationOld,
|
||||
selectedSourceLine,
|
||||
perSaveObj,
|
||||
}: Props) {
|
||||
const usedCompilationRef = useRef<api.Compilation | null>(null);
|
||||
const problemState = getProblemState(compilation);
|
||||
const [threeWayDiffBase] = useThreeWayDiffBase();
|
||||
const [threeWayDiffEnabled, setThreeWayDiffEnabled] = useState(false);
|
||||
const prevCompilation = usedCompilationRef.current;
|
||||
|
||||
// Only update the diff if it's never been set or if the compilation succeeded
|
||||
if (!usedCompilationRef.current || compilation.success) {
|
||||
usedCompilationRef.current = compilation
|
||||
usedCompilationRef.current = compilation;
|
||||
}
|
||||
|
||||
const usedDiff = usedCompilationRef.current?.diff_output ?? null
|
||||
const objdiffResult = usedCompilationRef.current?.objdiff_output ?? null
|
||||
const usedDiff = usedCompilationRef.current?.diff_output ?? null;
|
||||
const objdiffResult = usedCompilationRef.current?.objdiff_output ?? null;
|
||||
|
||||
// If this is the first time we re-render after a save, store the diff
|
||||
// as a possible three-way diff base.
|
||||
if (!perSaveObj.diff && usedCompilationRef.current?.success && usedDiff) {
|
||||
perSaveObj.diff = usedDiff
|
||||
perSaveObj.diff = usedDiff;
|
||||
}
|
||||
|
||||
const prevDiffRef = useRef<api.DiffOutput | null>(null)
|
||||
const prevDiffRef = useRef<api.DiffOutput | null>(null);
|
||||
|
||||
let usedBase: api.DiffOutput;
|
||||
if (threeWayDiffBase === ThreeWayDiffBase.SAVED) {
|
||||
usedBase = perSaveObj.diff ?? null
|
||||
prevDiffRef.current = null
|
||||
usedBase = perSaveObj.diff ?? null;
|
||||
prevDiffRef.current = null;
|
||||
} else {
|
||||
if (compilation.success && compilation !== prevCompilation) {
|
||||
prevDiffRef.current = prevCompilation?.diff_output ?? null
|
||||
prevDiffRef.current = prevCompilation?.diff_output ?? null;
|
||||
}
|
||||
usedBase = prevDiffRef.current ?? null
|
||||
usedBase = prevDiffRef.current ?? null;
|
||||
}
|
||||
|
||||
const diff = useMemo(
|
||||
() => {
|
||||
if (threeWayDiffEnabled)
|
||||
return interdiff(usedDiff, usedBase)
|
||||
else
|
||||
return usedDiff
|
||||
},
|
||||
[threeWayDiffEnabled, usedDiff, usedBase]
|
||||
)
|
||||
const diff = useMemo(() => {
|
||||
if (threeWayDiffEnabled) return interdiff(usedDiff, usedBase);
|
||||
else return usedDiff;
|
||||
}, [threeWayDiffEnabled, usedDiff, usedBase]);
|
||||
|
||||
const container = useRef<HTMLDivElement>(null)
|
||||
const allotment = useRef<AllotmentHandle>(null)
|
||||
const container = useRef<HTMLDivElement>(null);
|
||||
const allotment = useRef<AllotmentHandle>(null);
|
||||
|
||||
const problemsCollapsedHeight = 37
|
||||
const problemsDefaultHeight = 320
|
||||
const [isProblemsCollapsed, setIsProblemsCollapsed] = useState(problemState === ProblemState.NO_PROBLEMS)
|
||||
const problemsCollapsedHeight = 37;
|
||||
const problemsDefaultHeight = 320;
|
||||
const [isProblemsCollapsed, setIsProblemsCollapsed] = useState(
|
||||
problemState === ProblemState.NO_PROBLEMS,
|
||||
);
|
||||
|
||||
return <div ref={container} className="size-full">
|
||||
<Allotment
|
||||
ref={allotment}
|
||||
vertical
|
||||
onChange={([_top, bottom]) => {
|
||||
if (_top === undefined || bottom === undefined) {
|
||||
return
|
||||
}
|
||||
setIsProblemsCollapsed(bottom <= problemsCollapsedHeight)
|
||||
}}
|
||||
>
|
||||
<Allotment.Pane>
|
||||
<Diff
|
||||
diff={diff || objdiffResult}
|
||||
diffLabel={scratch.diff_label}
|
||||
isCompiling={isCompiling}
|
||||
isCurrentOutdated={isCompilationOld || problemState === ProblemState.ERRORS}
|
||||
threeWayDiffEnabled={threeWayDiffEnabled}
|
||||
setThreeWayDiffEnabled={setThreeWayDiffEnabled}
|
||||
threeWayDiffBase={threeWayDiffBase}
|
||||
selectedSourceLine={selectedSourceLine}
|
||||
/>
|
||||
</Allotment.Pane>
|
||||
<Allotment.Pane
|
||||
minSize={problemsCollapsedHeight}
|
||||
preferredSize={isProblemsCollapsed ? problemsCollapsedHeight : problemsDefaultHeight}
|
||||
return (
|
||||
<div ref={container} className="size-full">
|
||||
<Allotment
|
||||
ref={allotment}
|
||||
vertical
|
||||
onChange={([_top, bottom]) => {
|
||||
if (_top === undefined || bottom === undefined) {
|
||||
return;
|
||||
}
|
||||
setIsProblemsCollapsed(bottom <= problemsCollapsedHeight);
|
||||
}}
|
||||
>
|
||||
<div className="flex size-full flex-col">
|
||||
<h2 className="flex items-center border-b border-b-gray-5 p-1 pl-3">
|
||||
<GhostButton
|
||||
className="flex w-max grow justify-between text-gray-11"
|
||||
onClick={() => {
|
||||
const containerHeight = container.current?.clientHeight ?? 0
|
||||
const newProblemsHeight = isProblemsCollapsed ? problemsDefaultHeight : problemsCollapsedHeight
|
||||
allotment.current?.resize([
|
||||
containerHeight - newProblemsHeight,
|
||||
newProblemsHeight,
|
||||
])
|
||||
}}
|
||||
>
|
||||
<span className="font-medium text-sm">
|
||||
{(problemState === ProblemState.NO_PROBLEMS) ? "No problems" : "Problems"}
|
||||
</span>
|
||||
{isProblemsCollapsed ? <ChevronUpIcon /> : <ChevronDownIcon />}
|
||||
</GhostButton>
|
||||
</h2>
|
||||
<Allotment.Pane>
|
||||
<Diff
|
||||
diff={diff || objdiffResult}
|
||||
diffLabel={scratch.diff_label}
|
||||
isCompiling={isCompiling}
|
||||
isCurrentOutdated={
|
||||
isCompilationOld ||
|
||||
problemState === ProblemState.ERRORS
|
||||
}
|
||||
threeWayDiffEnabled={threeWayDiffEnabled}
|
||||
setThreeWayDiffEnabled={setThreeWayDiffEnabled}
|
||||
threeWayDiffBase={threeWayDiffBase}
|
||||
selectedSourceLine={selectedSourceLine}
|
||||
/>
|
||||
</Allotment.Pane>
|
||||
<Allotment.Pane
|
||||
minSize={problemsCollapsedHeight}
|
||||
preferredSize={
|
||||
isProblemsCollapsed
|
||||
? problemsCollapsedHeight
|
||||
: problemsDefaultHeight
|
||||
}
|
||||
>
|
||||
<div className="flex size-full flex-col">
|
||||
<h2 className="flex items-center border-b border-b-gray-5 p-1 pl-3">
|
||||
<GhostButton
|
||||
className="flex w-max grow justify-between text-gray-11"
|
||||
onClick={() => {
|
||||
const containerHeight =
|
||||
container.current?.clientHeight ?? 0;
|
||||
const newProblemsHeight =
|
||||
isProblemsCollapsed
|
||||
? problemsDefaultHeight
|
||||
: problemsCollapsedHeight;
|
||||
allotment.current?.resize([
|
||||
containerHeight - newProblemsHeight,
|
||||
newProblemsHeight,
|
||||
]);
|
||||
}}
|
||||
>
|
||||
<span className="font-medium text-sm">
|
||||
{problemState === ProblemState.NO_PROBLEMS
|
||||
? "No problems"
|
||||
: "Problems"}
|
||||
</span>
|
||||
{isProblemsCollapsed ? (
|
||||
<ChevronUpIcon />
|
||||
) : (
|
||||
<ChevronDownIcon />
|
||||
)}
|
||||
</GhostButton>
|
||||
</h2>
|
||||
|
||||
<div className="h-full grow overflow-auto whitespace-pre px-3 py-2 font-mono text-xs leading-snug">
|
||||
<Ansi>{compilation.compiler_output}</Ansi>
|
||||
<div className="h-full grow overflow-auto whitespace-pre px-3 py-2 font-mono text-xs leading-snug">
|
||||
<Ansi>{compilation.compiler_output}</Ansi>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Allotment.Pane>
|
||||
</Allotment>
|
||||
</div>
|
||||
</Allotment.Pane>
|
||||
</Allotment>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,44 +1,52 @@
|
||||
/* eslint css-modules/no-unused-class: off */
|
||||
|
||||
import { createContext, type CSSProperties, forwardRef, type HTMLAttributes, type MutableRefObject, useRef, useState } from "react"
|
||||
import {
|
||||
createContext,
|
||||
type CSSProperties,
|
||||
forwardRef,
|
||||
type HTMLAttributes,
|
||||
type MutableRefObject,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
import { VersionsIcon, CopyIcon } from "@primer/octicons-react"
|
||||
import type { EditorView } from "codemirror"
|
||||
import type { DiffResult } from "objdiff-wasm"
|
||||
import AutoSizer from "react-virtualized-auto-sizer"
|
||||
import { FixedSizeList } from "react-window"
|
||||
import { VersionsIcon, CopyIcon } from "@primer/octicons-react";
|
||||
import type { EditorView } from "codemirror";
|
||||
import type { DiffResult } from "objdiff-wasm";
|
||||
import AutoSizer from "react-virtualized-auto-sizer";
|
||||
import { FixedSizeList } from "react-window";
|
||||
|
||||
import type * as api from "@/lib/api"
|
||||
import { useSize } from "@/lib/hooks"
|
||||
import { ThreeWayDiffBase, useCodeFontSize } from "@/lib/settings"
|
||||
import type * as api from "@/lib/api";
|
||||
import { useSize } from "@/lib/hooks";
|
||||
import { ThreeWayDiffBase, useCodeFontSize } from "@/lib/settings";
|
||||
|
||||
import Loading from "../loading.svg"
|
||||
import Loading from "../loading.svg";
|
||||
|
||||
import styles from "./Diff.module.scss"
|
||||
import * as AsmDiffer from "./DiffRowAsmDiffer"
|
||||
import * as Objdiff from "./DiffRowObjdiff"
|
||||
import DragBar from "./DragBar"
|
||||
import { useHighlighers } from "./Highlighter"
|
||||
import styles from "./Diff.module.scss";
|
||||
import * as AsmDiffer from "./DiffRowAsmDiffer";
|
||||
import * as Objdiff from "./DiffRowObjdiff";
|
||||
import DragBar from "./DragBar";
|
||||
import { useHighlighers } from "./Highlighter";
|
||||
|
||||
const copyDiffContentsToClipboard = (diff: api.DiffOutput, kind: string) => {
|
||||
// kind is either "base", "current", or "previous"
|
||||
const contents = diff.rows.map(row => {
|
||||
let text = ""
|
||||
const contents = diff.rows.map((row) => {
|
||||
let text = "";
|
||||
if (kind === "base" && row.base) {
|
||||
text = row.base.text.map(t => t.text).join("")
|
||||
text = row.base.text.map((t) => t.text).join("");
|
||||
} else if (kind === "current" && row.current) {
|
||||
text = row.current.text.map(t => t.text).join("")
|
||||
text = row.current.text.map((t) => t.text).join("");
|
||||
} else if (kind === "previous" && row.previous) {
|
||||
text = row.previous.text.map(t => t.text).join("")
|
||||
text = row.previous.text.map((t) => t.text).join("");
|
||||
}
|
||||
return text
|
||||
})
|
||||
return text;
|
||||
});
|
||||
|
||||
navigator.clipboard.writeText(contents.join("\n"))
|
||||
}
|
||||
navigator.clipboard.writeText(contents.join("\n"));
|
||||
};
|
||||
|
||||
// Small component for the copy button
|
||||
function CopyButton({ diff, kind }: { diff: api.DiffOutput, kind: string }) {
|
||||
function CopyButton({ diff, kind }: { diff: api.DiffOutput; kind: string }) {
|
||||
return (
|
||||
<button
|
||||
className={styles.copyButton} // Add a new style for the button
|
||||
@@ -47,181 +55,260 @@ function CopyButton({ diff, kind }: { diff: api.DiffOutput, kind: string }) {
|
||||
>
|
||||
<CopyIcon size={16} />
|
||||
</button>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// https://github.com/bvaughn/react-window#can-i-add-padding-to-the-top-and-bottom-of-a-list
|
||||
const innerElementType = forwardRef<HTMLUListElement, HTMLAttributes<HTMLUListElement>>(({ style, ...rest }, ref) => {
|
||||
return <ul
|
||||
ref={ref}
|
||||
style={{
|
||||
...style,
|
||||
height: `${Number.parseFloat(style.height.toString()) + PADDING_TOP + PADDING_BOTTOM}px`,
|
||||
}}
|
||||
{...rest}
|
||||
/>
|
||||
})
|
||||
innerElementType.displayName = "innerElementType"
|
||||
const innerElementType = forwardRef<
|
||||
HTMLUListElement,
|
||||
HTMLAttributes<HTMLUListElement>
|
||||
>(({ style, ...rest }, ref) => {
|
||||
return (
|
||||
<ul
|
||||
ref={ref}
|
||||
style={{
|
||||
...style,
|
||||
height: `${Number.parseFloat(style.height.toString()) + PADDING_TOP + PADDING_BOTTOM}px`,
|
||||
}}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
});
|
||||
innerElementType.displayName = "innerElementType";
|
||||
|
||||
const isAsmDifferOutput = (diff: api.DiffOutput | DiffResult): diff is api.DiffOutput => {
|
||||
return Object.prototype.hasOwnProperty.call(diff, "arch_str")
|
||||
}
|
||||
const isAsmDifferOutput = (
|
||||
diff: api.DiffOutput | DiffResult,
|
||||
): diff is api.DiffOutput => {
|
||||
return Object.prototype.hasOwnProperty.call(diff, "arch_str");
|
||||
};
|
||||
|
||||
function DiffBody({ diff, diffLabel, fontSize }: { diff: api.DiffOutput | DiffResult | null, diffLabel: string | null, fontSize: number | undefined }) {
|
||||
const { highlighters, setHighlightAll } = useHighlighers(3)
|
||||
function DiffBody({
|
||||
diff,
|
||||
diffLabel,
|
||||
fontSize,
|
||||
}: {
|
||||
diff: api.DiffOutput | DiffResult | null;
|
||||
diffLabel: string | null;
|
||||
fontSize: number | undefined;
|
||||
}) {
|
||||
const { highlighters, setHighlightAll } = useHighlighers(3);
|
||||
|
||||
if (!diff) {
|
||||
return <div className={styles.bodyContainer} />
|
||||
return <div className={styles.bodyContainer} />;
|
||||
}
|
||||
|
||||
let itemData: AsmDiffer.DiffListData | Objdiff.DiffListData
|
||||
let DiffRow: typeof AsmDiffer.DiffRow | typeof Objdiff.DiffRow
|
||||
let itemData: AsmDiffer.DiffListData | Objdiff.DiffListData;
|
||||
let DiffRow: typeof AsmDiffer.DiffRow | typeof Objdiff.DiffRow;
|
||||
if (isAsmDifferOutput(diff)) {
|
||||
itemData = AsmDiffer.createDiffListData(diff, diffLabel, highlighters)
|
||||
DiffRow = AsmDiffer.DiffRow
|
||||
itemData = AsmDiffer.createDiffListData(diff, diffLabel, highlighters);
|
||||
DiffRow = AsmDiffer.DiffRow;
|
||||
} else {
|
||||
itemData = Objdiff.createDiffListData(diff, diffLabel, highlighters)
|
||||
DiffRow = Objdiff.DiffRow
|
||||
itemData = Objdiff.createDiffListData(diff, diffLabel, highlighters);
|
||||
DiffRow = Objdiff.DiffRow;
|
||||
}
|
||||
|
||||
return <div
|
||||
className={styles.bodyContainer}
|
||||
onClick={() => {
|
||||
// If clicks propagate to the container, clear all
|
||||
setHighlightAll(null)
|
||||
}}
|
||||
>
|
||||
<AutoSizer>
|
||||
{({ height, width }: {height: number|undefined, width:number|undefined}) => (
|
||||
<FixedSizeList
|
||||
className={styles.body}
|
||||
itemCount={itemData.itemCount}
|
||||
itemData={itemData}
|
||||
itemSize={(fontSize ?? 12) * 1.33}
|
||||
overscanCount={40}
|
||||
width={width}
|
||||
height={height}
|
||||
innerElementType={innerElementType}
|
||||
>
|
||||
{DiffRow as any}
|
||||
</FixedSizeList>
|
||||
)}
|
||||
</AutoSizer>
|
||||
</div>
|
||||
}
|
||||
|
||||
function ThreeWayToggleButton({ enabled, setEnabled }: { enabled: boolean, setEnabled: (enabled: boolean) => void }) {
|
||||
return <button
|
||||
className={styles.threeWayToggle}
|
||||
onClick={() => {
|
||||
setEnabled(!enabled)
|
||||
}}
|
||||
title={enabled ? "Disable three-way diffing" : "Enable three-way diffing"}
|
||||
>
|
||||
<VersionsIcon size={24} />
|
||||
<div className={styles.threeWayToggleNumber}>
|
||||
{enabled ? "3" : "2"}
|
||||
return (
|
||||
<div
|
||||
className={styles.bodyContainer}
|
||||
onClick={() => {
|
||||
// If clicks propagate to the container, clear all
|
||||
setHighlightAll(null);
|
||||
}}
|
||||
>
|
||||
<AutoSizer>
|
||||
{({
|
||||
height,
|
||||
width,
|
||||
}: {
|
||||
height: number | undefined;
|
||||
width: number | undefined;
|
||||
}) => (
|
||||
<FixedSizeList
|
||||
className={styles.body}
|
||||
itemCount={itemData.itemCount}
|
||||
itemData={itemData}
|
||||
itemSize={(fontSize ?? 12) * 1.33}
|
||||
overscanCount={40}
|
||||
width={width}
|
||||
height={height}
|
||||
innerElementType={innerElementType}
|
||||
>
|
||||
{DiffRow as any}
|
||||
</FixedSizeList>
|
||||
)}
|
||||
</AutoSizer>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function scrollToLineNumber(editorView: MutableRefObject<EditorView>, lineNumber: number) {
|
||||
function ThreeWayToggleButton({
|
||||
enabled,
|
||||
setEnabled,
|
||||
}: { enabled: boolean; setEnabled: (enabled: boolean) => void }) {
|
||||
return (
|
||||
<button
|
||||
className={styles.threeWayToggle}
|
||||
onClick={() => {
|
||||
setEnabled(!enabled);
|
||||
}}
|
||||
title={
|
||||
enabled
|
||||
? "Disable three-way diffing"
|
||||
: "Enable three-way diffing"
|
||||
}
|
||||
>
|
||||
<VersionsIcon size={24} />
|
||||
<div className={styles.threeWayToggleNumber}>
|
||||
{enabled ? "3" : "2"}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function scrollToLineNumber(
|
||||
editorView: MutableRefObject<EditorView>,
|
||||
lineNumber: number,
|
||||
) {
|
||||
if (!editorView) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
if (lineNumber <= editorView.current.state.doc.lines) {
|
||||
// check if the source line <= number of lines
|
||||
// which can be false if pragmas are used to force line numbers
|
||||
const line = editorView.current.state.doc.line(lineNumber)
|
||||
const line = editorView.current.state.doc.line(lineNumber);
|
||||
if (line) {
|
||||
const { top } = editorView.current.lineBlockAt(line.to)
|
||||
editorView.current.scrollDOM.scrollTo({ top, behavior: "smooth" })
|
||||
const { top } = editorView.current.lineBlockAt(line.to);
|
||||
editorView.current.scrollDOM.scrollTo({ top, behavior: "smooth" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const PADDING_TOP = 8
|
||||
export const PADDING_BOTTOM = 8
|
||||
export const PADDING_TOP = 8;
|
||||
export const PADDING_BOTTOM = 8;
|
||||
|
||||
export const SelectedSourceLineContext = createContext<number | null>(null)
|
||||
export const SelectedSourceLineContext = createContext<number | null>(null);
|
||||
|
||||
export type Props = {
|
||||
diff: api.DiffOutput | DiffResult | null
|
||||
diffLabel: string | null
|
||||
isCompiling: boolean
|
||||
isCurrentOutdated: boolean
|
||||
threeWayDiffEnabled: boolean
|
||||
setThreeWayDiffEnabled: (value: boolean) => void
|
||||
threeWayDiffBase: ThreeWayDiffBase
|
||||
selectedSourceLine: number | null
|
||||
}
|
||||
diff: api.DiffOutput | DiffResult | null;
|
||||
diffLabel: string | null;
|
||||
isCompiling: boolean;
|
||||
isCurrentOutdated: boolean;
|
||||
threeWayDiffEnabled: boolean;
|
||||
setThreeWayDiffEnabled: (value: boolean) => void;
|
||||
threeWayDiffBase: ThreeWayDiffBase;
|
||||
selectedSourceLine: number | null;
|
||||
};
|
||||
|
||||
export default function Diff({ diff, diffLabel, isCompiling, isCurrentOutdated, threeWayDiffEnabled, setThreeWayDiffEnabled, threeWayDiffBase, selectedSourceLine }: Props) {
|
||||
const [fontSize] = useCodeFontSize()
|
||||
export default function Diff({
|
||||
diff,
|
||||
diffLabel,
|
||||
isCompiling,
|
||||
isCurrentOutdated,
|
||||
threeWayDiffEnabled,
|
||||
setThreeWayDiffEnabled,
|
||||
threeWayDiffBase,
|
||||
selectedSourceLine,
|
||||
}: Props) {
|
||||
const [fontSize] = useCodeFontSize();
|
||||
|
||||
const container = useSize<HTMLDivElement>()
|
||||
const container = useSize<HTMLDivElement>();
|
||||
|
||||
const [bar1Pos, setBar1Pos] = useState(Number.NaN)
|
||||
const [bar2Pos, setBar2Pos] = useState(Number.NaN)
|
||||
const [bar1Pos, setBar1Pos] = useState(Number.NaN);
|
||||
const [bar2Pos, setBar2Pos] = useState(Number.NaN);
|
||||
|
||||
const columnMinWidth = 100
|
||||
const clampedBar1Pos = Math.max(columnMinWidth, Math.min(container.width - columnMinWidth - (threeWayDiffEnabled ? columnMinWidth : 0), bar1Pos))
|
||||
const clampedBar2Pos = threeWayDiffEnabled ? Math.max(clampedBar1Pos + columnMinWidth, Math.min(container.width - columnMinWidth, bar2Pos)) : container.width
|
||||
const columnMinWidth = 100;
|
||||
const clampedBar1Pos = Math.max(
|
||||
columnMinWidth,
|
||||
Math.min(
|
||||
container.width -
|
||||
columnMinWidth -
|
||||
(threeWayDiffEnabled ? columnMinWidth : 0),
|
||||
bar1Pos,
|
||||
),
|
||||
);
|
||||
const clampedBar2Pos = threeWayDiffEnabled
|
||||
? Math.max(
|
||||
clampedBar1Pos + columnMinWidth,
|
||||
Math.min(container.width - columnMinWidth, bar2Pos),
|
||||
)
|
||||
: container.width;
|
||||
|
||||
// Distribute the bar positions across the container when its width changes
|
||||
const updateBarPositions = (threeWayDiffEnabled: boolean) => {
|
||||
const numSections = threeWayDiffEnabled ? 3 : 2
|
||||
setBar1Pos(container.width / numSections)
|
||||
setBar2Pos(container.width / numSections * 2)
|
||||
}
|
||||
const lastContainerWidthRef = useRef(Number.NaN)
|
||||
const numSections = threeWayDiffEnabled ? 3 : 2;
|
||||
setBar1Pos(container.width / numSections);
|
||||
setBar2Pos((container.width / numSections) * 2);
|
||||
};
|
||||
const lastContainerWidthRef = useRef(Number.NaN);
|
||||
if (lastContainerWidthRef.current !== container.width && container.width) {
|
||||
lastContainerWidthRef.current = container.width
|
||||
updateBarPositions(threeWayDiffEnabled)
|
||||
lastContainerWidthRef.current = container.width;
|
||||
updateBarPositions(threeWayDiffEnabled);
|
||||
}
|
||||
|
||||
const threeWayButton = <>
|
||||
<div className={styles.spacer} />
|
||||
<ThreeWayToggleButton
|
||||
enabled={threeWayDiffEnabled}
|
||||
setEnabled={(enabled: boolean) => {
|
||||
updateBarPositions(enabled)
|
||||
setThreeWayDiffEnabled(enabled)
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
const threeWayButton = (
|
||||
<>
|
||||
<div className={styles.spacer} />
|
||||
<ThreeWayToggleButton
|
||||
enabled={threeWayDiffEnabled}
|
||||
setEnabled={(enabled: boolean) => {
|
||||
updateBarPositions(enabled);
|
||||
setThreeWayDiffEnabled(enabled);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
return <div
|
||||
ref={container.ref}
|
||||
className={styles.diff}
|
||||
style={{
|
||||
"--diff-font-size": typeof fontSize === "number" ? `${fontSize}px` : "",
|
||||
"--diff-left-width": `${clampedBar1Pos}px`,
|
||||
"--diff-right-width": `${container.width - clampedBar2Pos}px`,
|
||||
"--diff-current-filter": isCurrentOutdated ? "grayscale(25%) brightness(70%)" : "",
|
||||
} as CSSProperties}
|
||||
>
|
||||
<DragBar pos={clampedBar1Pos} onChange={setBar1Pos} />
|
||||
{threeWayDiffEnabled && <DragBar pos={clampedBar2Pos} onChange={setBar2Pos} />}
|
||||
<div className={styles.headers}>
|
||||
<div className={styles.header}>
|
||||
Target
|
||||
<CopyButton diff={diff as api.DiffOutput} kind="base" />
|
||||
return (
|
||||
<div
|
||||
ref={container.ref}
|
||||
className={styles.diff}
|
||||
style={
|
||||
{
|
||||
"--diff-font-size":
|
||||
typeof fontSize === "number" ? `${fontSize}px` : "",
|
||||
"--diff-left-width": `${clampedBar1Pos}px`,
|
||||
"--diff-right-width": `${container.width - clampedBar2Pos}px`,
|
||||
"--diff-current-filter": isCurrentOutdated
|
||||
? "grayscale(25%) brightness(70%)"
|
||||
: "",
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<DragBar pos={clampedBar1Pos} onChange={setBar1Pos} />
|
||||
{threeWayDiffEnabled && (
|
||||
<DragBar pos={clampedBar2Pos} onChange={setBar2Pos} />
|
||||
)}
|
||||
<div className={styles.headers}>
|
||||
<div className={styles.header}>
|
||||
Target
|
||||
<CopyButton diff={diff as api.DiffOutput} kind="base" />
|
||||
</div>
|
||||
<div className={styles.header}>
|
||||
Current
|
||||
<CopyButton diff={diff as api.DiffOutput} kind="current" />
|
||||
{isCompiling && <Loading width={20} height={20} />}
|
||||
{!threeWayDiffEnabled && threeWayButton}
|
||||
</div>
|
||||
{threeWayDiffEnabled && (
|
||||
<div className={styles.header}>
|
||||
{threeWayDiffBase === ThreeWayDiffBase.SAVED
|
||||
? "Saved"
|
||||
: "Previous"}
|
||||
<CopyButton
|
||||
diff={diff as api.DiffOutput}
|
||||
kind="previous"
|
||||
/>
|
||||
{threeWayButton}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.header}>
|
||||
Current
|
||||
<CopyButton diff={diff as api.DiffOutput} kind="current" />
|
||||
{isCompiling && <Loading width={20} height={20} />}
|
||||
{!threeWayDiffEnabled && threeWayButton}
|
||||
</div>
|
||||
{threeWayDiffEnabled && <div className={styles.header}>
|
||||
{threeWayDiffBase === ThreeWayDiffBase.SAVED ? "Saved" : "Previous"}
|
||||
<CopyButton diff={diff as api.DiffOutput} kind="previous" />
|
||||
{threeWayButton}
|
||||
</div>}
|
||||
<SelectedSourceLineContext.Provider value={selectedSourceLine}>
|
||||
<DiffBody
|
||||
diff={diff}
|
||||
diffLabel={diffLabel}
|
||||
fontSize={fontSize}
|
||||
/>
|
||||
</SelectedSourceLineContext.Provider>
|
||||
</div>
|
||||
<SelectedSourceLineContext.Provider value={selectedSourceLine}>
|
||||
<DiffBody diff={diff} diffLabel={diffLabel} fontSize={fontSize} />
|
||||
</SelectedSourceLineContext.Provider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,111 +1,154 @@
|
||||
/* eslint css-modules/no-unused-class: off */
|
||||
|
||||
import { type CSSProperties, type MutableRefObject, memo, useContext } from "react"
|
||||
import {
|
||||
type CSSProperties,
|
||||
type MutableRefObject,
|
||||
memo,
|
||||
useContext,
|
||||
} from "react";
|
||||
|
||||
import classNames from "classnames"
|
||||
import type { EditorView } from "codemirror"
|
||||
import memoize from "memoize-one"
|
||||
import { areEqual } from "react-window"
|
||||
import classNames from "classnames";
|
||||
import type { EditorView } from "codemirror";
|
||||
import memoize from "memoize-one";
|
||||
import { areEqual } from "react-window";
|
||||
|
||||
import type * as api from "@/lib/api"
|
||||
import type * as api from "@/lib/api";
|
||||
|
||||
import { ScrollContext } from "../ScrollContext"
|
||||
import { ScrollContext } from "../ScrollContext";
|
||||
|
||||
import { PADDING_TOP, SelectedSourceLineContext, scrollToLineNumber } from "./Diff"
|
||||
import styles from "./Diff.module.scss"
|
||||
import type { Highlighter } from "./Highlighter"
|
||||
import {
|
||||
PADDING_TOP,
|
||||
SelectedSourceLineContext,
|
||||
scrollToLineNumber,
|
||||
} from "./Diff";
|
||||
import styles from "./Diff.module.scss";
|
||||
import type { Highlighter } from "./Highlighter";
|
||||
|
||||
// Regex for tokenizing lines for click-to-highlight purposes.
|
||||
// Strings matched by the first regex group (spaces, punctuation)
|
||||
// are treated as non-highlightable.
|
||||
const RE_TOKEN = /([ \t,()[\]:]+|~>)|%(?:lo|hi)\([^)]+\)|[^ \t,()[\]:]+/g
|
||||
const RE_TOKEN = /([ \t,()[\]:]+|~>)|%(?:lo|hi)\([^)]+\)|[^ \t,()[\]:]+/g;
|
||||
|
||||
function FormatDiffText({ texts, highlighter }: {
|
||||
texts: api.DiffText[]
|
||||
highlighter: Highlighter
|
||||
function FormatDiffText({
|
||||
texts,
|
||||
highlighter,
|
||||
}: {
|
||||
texts: api.DiffText[];
|
||||
highlighter: Highlighter;
|
||||
}) {
|
||||
return <> {
|
||||
texts.map((t, index1) =>
|
||||
Array.from(t.text.matchAll(RE_TOKEN)).map((match, index2) => {
|
||||
const text = match[0]
|
||||
const isToken = !match[1]
|
||||
const key = `${index1},${index2}`
|
||||
return (
|
||||
<>
|
||||
{" "}
|
||||
{texts.map((t, index1) =>
|
||||
Array.from(t.text.matchAll(RE_TOKEN)).map((match, index2) => {
|
||||
const text = match[0];
|
||||
const isToken = !match[1];
|
||||
const key = `${index1},${index2}`;
|
||||
|
||||
let className: string
|
||||
if (t.format === "rotation") {
|
||||
className = styles[`rotation${t.index % 9}`]
|
||||
} else if (t.format) {
|
||||
className = styles[t.format]
|
||||
}
|
||||
let className: string;
|
||||
if (t.format === "rotation") {
|
||||
className = styles[`rotation${t.index % 9}`];
|
||||
} else if (t.format) {
|
||||
className = styles[t.format];
|
||||
}
|
||||
|
||||
return <span
|
||||
key={key}
|
||||
className={classNames(className, {
|
||||
[styles.highlightable]: isToken,
|
||||
[styles.highlighted]: (highlighter.value === text),
|
||||
})}
|
||||
onClick={e => {
|
||||
if (isToken) {
|
||||
highlighter.select(text)
|
||||
e.stopPropagation()
|
||||
}
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
})
|
||||
)
|
||||
}</>
|
||||
return (
|
||||
<span
|
||||
key={key}
|
||||
className={classNames(className, {
|
||||
[styles.highlightable]: isToken,
|
||||
[styles.highlighted]:
|
||||
highlighter.value === text,
|
||||
})}
|
||||
onClick={(e) => {
|
||||
if (isToken) {
|
||||
highlighter.select(text);
|
||||
e.stopPropagation();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
);
|
||||
}),
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function DiffCell({ cell, className, highlighter }: {
|
||||
cell: api.DiffCell | undefined
|
||||
className?: string
|
||||
highlighter: Highlighter
|
||||
function DiffCell({
|
||||
cell,
|
||||
className,
|
||||
highlighter,
|
||||
}: {
|
||||
cell: api.DiffCell | undefined;
|
||||
className?: string;
|
||||
highlighter: Highlighter;
|
||||
}) {
|
||||
const selectedSourceLine = useContext(SelectedSourceLineContext)
|
||||
const sourceEditor = useContext<MutableRefObject<EditorView>>(ScrollContext)
|
||||
const hasLineNo = typeof cell?.src_line !== "undefined"
|
||||
const selectedSourceLine = useContext(SelectedSourceLineContext);
|
||||
const sourceEditor =
|
||||
useContext<MutableRefObject<EditorView>>(ScrollContext);
|
||||
const hasLineNo = typeof cell?.src_line !== "undefined";
|
||||
|
||||
if (!cell)
|
||||
return <div className={classNames(styles.cell, className)} />
|
||||
if (!cell) return <div className={classNames(styles.cell, className)} />;
|
||||
|
||||
return <div
|
||||
className={classNames(styles.cell, className, {
|
||||
[styles.highlight]: hasLineNo && cell.src_line === selectedSourceLine,
|
||||
})}
|
||||
>
|
||||
{hasLineNo && <span className={styles.lineNumber}><button onClick={() => scrollToLineNumber(sourceEditor, cell.src_line)}>{cell.src_line}</button></span>}
|
||||
<FormatDiffText texts={cell.text} highlighter={highlighter} />
|
||||
</div>
|
||||
return (
|
||||
<div
|
||||
className={classNames(styles.cell, className, {
|
||||
[styles.highlight]:
|
||||
hasLineNo && cell.src_line === selectedSourceLine,
|
||||
})}
|
||||
>
|
||||
{hasLineNo && (
|
||||
<span className={styles.lineNumber}>
|
||||
<button
|
||||
onClick={() =>
|
||||
scrollToLineNumber(sourceEditor, cell.src_line)
|
||||
}
|
||||
>
|
||||
{cell.src_line}
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
<FormatDiffText texts={cell.text} highlighter={highlighter} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export type DiffListData = {
|
||||
diff: api.DiffOutput | null
|
||||
itemCount: number
|
||||
highlighters: Highlighter[]
|
||||
}
|
||||
diff: api.DiffOutput | null;
|
||||
itemCount: number;
|
||||
highlighters: Highlighter[];
|
||||
};
|
||||
|
||||
export const createDiffListData = memoize((
|
||||
diff: api.DiffOutput | null,
|
||||
_diffLabel: string,
|
||||
highlighters: Highlighter[]
|
||||
): DiffListData => {
|
||||
return { diff, highlighters, itemCount: diff?.rows?.length ?? 0 }
|
||||
})
|
||||
export const createDiffListData = memoize(
|
||||
(
|
||||
diff: api.DiffOutput | null,
|
||||
_diffLabel: string,
|
||||
highlighters: Highlighter[],
|
||||
): DiffListData => {
|
||||
return { diff, highlighters, itemCount: diff?.rows?.length ?? 0 };
|
||||
},
|
||||
);
|
||||
|
||||
export const DiffRow = memo(function DiffRow({ data, index, style }: { data: DiffListData, index: number, style: CSSProperties }) {
|
||||
const row = data.diff?.rows?.[index]
|
||||
return <li
|
||||
className={styles.row}
|
||||
style={{
|
||||
...style,
|
||||
top: `${Number.parseFloat(style.top.toString()) + PADDING_TOP}px`,
|
||||
lineHeight: `${style.height.toString()}px`,
|
||||
}}
|
||||
>
|
||||
<DiffCell cell={row.base} highlighter={data.highlighters[0]} />
|
||||
<DiffCell cell={row.current} highlighter={data.highlighters[1]} />
|
||||
<DiffCell cell={row.previous} highlighter={data.highlighters[2]} />
|
||||
</li>
|
||||
}, areEqual)
|
||||
export const DiffRow = memo(function DiffRow({
|
||||
data,
|
||||
index,
|
||||
style,
|
||||
}: { data: DiffListData; index: number; style: CSSProperties }) {
|
||||
const row = data.diff?.rows?.[index];
|
||||
return (
|
||||
<li
|
||||
className={styles.row}
|
||||
style={{
|
||||
...style,
|
||||
top: `${Number.parseFloat(style.top.toString()) + PADDING_TOP}px`,
|
||||
lineHeight: `${style.height.toString()}px`,
|
||||
}}
|
||||
>
|
||||
<DiffCell cell={row.base} highlighter={data.highlighters[0]} />
|
||||
<DiffCell cell={row.current} highlighter={data.highlighters[1]} />
|
||||
<DiffCell cell={row.previous} highlighter={data.highlighters[2]} />
|
||||
</li>
|
||||
);
|
||||
}, areEqual);
|
||||
|
||||
@@ -1,209 +1,282 @@
|
||||
/* eslint css-modules/no-unused-class: off */
|
||||
|
||||
import { type CSSProperties, type MutableRefObject, memo, useContext } from "react"
|
||||
import {
|
||||
type CSSProperties,
|
||||
type MutableRefObject,
|
||||
memo,
|
||||
useContext,
|
||||
} from "react";
|
||||
|
||||
import classNames from "classnames"
|
||||
import type { EditorView } from "codemirror"
|
||||
import memoize from "memoize-one"
|
||||
import { DiffKind, type DiffResult, type FunctionDiff, type InstructionDiff, type ObjectDiff, displayDiff, oneof } from "objdiff-wasm"
|
||||
import { areEqual } from "react-window"
|
||||
import classNames from "classnames";
|
||||
import type { EditorView } from "codemirror";
|
||||
import memoize from "memoize-one";
|
||||
import {
|
||||
DiffKind,
|
||||
type DiffResult,
|
||||
type FunctionDiff,
|
||||
type InstructionDiff,
|
||||
type ObjectDiff,
|
||||
displayDiff,
|
||||
oneof,
|
||||
} from "objdiff-wasm";
|
||||
import { areEqual } from "react-window";
|
||||
|
||||
import { ScrollContext } from "../ScrollContext"
|
||||
import { ScrollContext } from "../ScrollContext";
|
||||
|
||||
import { PADDING_TOP, SelectedSourceLineContext, scrollToLineNumber } from "./Diff"
|
||||
import styles from "./Diff.module.scss"
|
||||
import type { Highlighter } from "./Highlighter"
|
||||
import {
|
||||
PADDING_TOP,
|
||||
SelectedSourceLineContext,
|
||||
scrollToLineNumber,
|
||||
} from "./Diff";
|
||||
import styles from "./Diff.module.scss";
|
||||
import type { Highlighter } from "./Highlighter";
|
||||
|
||||
function FormatDiffText({ insDiff, baseAddress, highlighter }: {
|
||||
insDiff: InstructionDiff
|
||||
baseAddress?: bigint
|
||||
highlighter?: Highlighter
|
||||
function FormatDiffText({
|
||||
insDiff,
|
||||
baseAddress,
|
||||
highlighter,
|
||||
}: {
|
||||
insDiff: InstructionDiff;
|
||||
baseAddress?: bigint;
|
||||
highlighter?: Highlighter;
|
||||
}) {
|
||||
const out: JSX.Element[] = []
|
||||
let index = 0
|
||||
displayDiff(insDiff, baseAddress || BigInt(0), t => {
|
||||
let className: string | null = null
|
||||
const out: JSX.Element[] = [];
|
||||
let index = 0;
|
||||
displayDiff(insDiff, baseAddress || BigInt(0), (t) => {
|
||||
let className: string | null = null;
|
||||
if (t.diff_index != null) {
|
||||
className = styles[`rotation${t.diff_index % 9}`]
|
||||
className = styles[`rotation${t.diff_index % 9}`];
|
||||
}
|
||||
let text = ""
|
||||
let postText = "" // unhighlightable text after the token
|
||||
let padTo = 0
|
||||
let isToken = false
|
||||
let text = "";
|
||||
let postText = ""; // unhighlightable text after the token
|
||||
let padTo = 0;
|
||||
let isToken = false;
|
||||
switch (t.type) {
|
||||
case "basic":
|
||||
text = t.text
|
||||
break
|
||||
case "basic_color":
|
||||
text = t.text
|
||||
className = styles[`rotation${t.index % 9}`]
|
||||
break
|
||||
case "line":
|
||||
text = (t.line_number || 0).toString(16)
|
||||
padTo = 5
|
||||
break
|
||||
case "address":
|
||||
text = (t.address || 0).toString(16)
|
||||
postText = ":"
|
||||
padTo = 5
|
||||
isToken = true
|
||||
break
|
||||
case "opcode":
|
||||
text = t.mnemonic
|
||||
padTo = 8
|
||||
isToken = true
|
||||
if (insDiff.diff_kind === DiffKind.DIFF_OP_MISMATCH) {
|
||||
className = styles.diff_change
|
||||
}
|
||||
break
|
||||
case "argument": {
|
||||
const value = oneof(t.value.value)
|
||||
switch (value.oneofKind) {
|
||||
case "signed":
|
||||
if (value.signed < 0) {
|
||||
text = `-0x${(-value.signed).toString(16)}`
|
||||
} else {
|
||||
text = `0x${value.signed.toString(16)}`
|
||||
case "basic":
|
||||
text = t.text;
|
||||
break;
|
||||
case "basic_color":
|
||||
text = t.text;
|
||||
className = styles[`rotation${t.index % 9}`];
|
||||
break;
|
||||
case "line":
|
||||
text = (t.line_number || 0).toString(16);
|
||||
padTo = 5;
|
||||
break;
|
||||
case "address":
|
||||
text = (t.address || 0).toString(16);
|
||||
postText = ":";
|
||||
padTo = 5;
|
||||
isToken = true;
|
||||
break;
|
||||
case "opcode":
|
||||
text = t.mnemonic;
|
||||
padTo = 8;
|
||||
isToken = true;
|
||||
if (insDiff.diff_kind === DiffKind.DIFF_OP_MISMATCH) {
|
||||
className = styles.diff_change;
|
||||
}
|
||||
break
|
||||
case "unsigned":
|
||||
text = `0x${value.unsigned.toString(16)}`
|
||||
break
|
||||
case "opaque":
|
||||
text = value.opaque
|
||||
break
|
||||
}
|
||||
isToken = true
|
||||
break
|
||||
}
|
||||
case "branch_dest":
|
||||
text = (t.address || 0).toString(16)
|
||||
isToken = true
|
||||
break
|
||||
case "symbol":
|
||||
text = t.target.symbol.demangled_name || t.target.symbol.name
|
||||
className = styles.symbol
|
||||
isToken = true
|
||||
break
|
||||
case "spacing":
|
||||
text = " ".repeat(t.count)
|
||||
break
|
||||
default:
|
||||
console.warn("Unknown text type", t)
|
||||
return null
|
||||
}
|
||||
out.push(<span
|
||||
key={index}
|
||||
className={classNames(className, {
|
||||
[styles.highlightable]: isToken,
|
||||
[styles.highlighted]: (highlighter?.value === text),
|
||||
})}
|
||||
onClick={e => {
|
||||
if (isToken) {
|
||||
highlighter?.select(text)
|
||||
e.stopPropagation()
|
||||
break;
|
||||
case "argument": {
|
||||
const value = oneof(t.value.value);
|
||||
switch (value.oneofKind) {
|
||||
case "signed":
|
||||
if (value.signed < 0) {
|
||||
text = `-0x${(-value.signed).toString(16)}`;
|
||||
} else {
|
||||
text = `0x${value.signed.toString(16)}`;
|
||||
}
|
||||
break;
|
||||
case "unsigned":
|
||||
text = `0x${value.unsigned.toString(16)}`;
|
||||
break;
|
||||
case "opaque":
|
||||
text = value.opaque;
|
||||
break;
|
||||
}
|
||||
}}
|
||||
>{text}</span>)
|
||||
index++
|
||||
isToken = true;
|
||||
break;
|
||||
}
|
||||
case "branch_dest":
|
||||
text = (t.address || 0).toString(16);
|
||||
isToken = true;
|
||||
break;
|
||||
case "symbol":
|
||||
text = t.target.symbol.demangled_name || t.target.symbol.name;
|
||||
className = styles.symbol;
|
||||
isToken = true;
|
||||
break;
|
||||
case "spacing":
|
||||
text = " ".repeat(t.count);
|
||||
break;
|
||||
default:
|
||||
console.warn("Unknown text type", t);
|
||||
return null;
|
||||
}
|
||||
out.push(
|
||||
<span
|
||||
key={index}
|
||||
className={classNames(className, {
|
||||
[styles.highlightable]: isToken,
|
||||
[styles.highlighted]: highlighter?.value === text,
|
||||
})}
|
||||
onClick={(e) => {
|
||||
if (isToken) {
|
||||
highlighter?.select(text);
|
||||
e.stopPropagation();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</span>,
|
||||
);
|
||||
index++;
|
||||
if (postText) {
|
||||
out.push(<span key={index}>{postText}</span>)
|
||||
index++
|
||||
out.push(<span key={index}>{postText}</span>);
|
||||
index++;
|
||||
}
|
||||
if (padTo > text.length + postText.length) {
|
||||
const spacing = " ".repeat(padTo - text.length - postText.length)
|
||||
out.push(<span key={index}>{spacing}</span>)
|
||||
index++
|
||||
const spacing = " ".repeat(padTo - text.length - postText.length);
|
||||
out.push(<span key={index}>{spacing}</span>);
|
||||
index++;
|
||||
}
|
||||
})
|
||||
return out
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function DiffCell({ cell, baseAddress, className, highlighter }: {
|
||||
cell: InstructionDiff | undefined
|
||||
baseAddress: bigint | undefined
|
||||
className?: string
|
||||
highlighter?: Highlighter
|
||||
function DiffCell({
|
||||
cell,
|
||||
baseAddress,
|
||||
className,
|
||||
highlighter,
|
||||
}: {
|
||||
cell: InstructionDiff | undefined;
|
||||
baseAddress: bigint | undefined;
|
||||
className?: string;
|
||||
highlighter?: Highlighter;
|
||||
}) {
|
||||
const selectedSourceLine = useContext(SelectedSourceLineContext)
|
||||
const sourceEditor = useContext<MutableRefObject<EditorView>>(ScrollContext)
|
||||
const hasLineNo = typeof cell?.instruction?.line_number !== "undefined"
|
||||
const selectedSourceLine = useContext(SelectedSourceLineContext);
|
||||
const sourceEditor =
|
||||
useContext<MutableRefObject<EditorView>>(ScrollContext);
|
||||
const hasLineNo = typeof cell?.instruction?.line_number !== "undefined";
|
||||
|
||||
if (!cell)
|
||||
return <div className={classNames(styles.cell, className)} />
|
||||
if (!cell) return <div className={classNames(styles.cell, className)} />;
|
||||
|
||||
const classes = []
|
||||
const classes = [];
|
||||
if (cell?.diff_kind) {
|
||||
classes.push(styles.diff_any)
|
||||
classes.push(styles.diff_any);
|
||||
}
|
||||
switch (cell?.diff_kind) {
|
||||
case DiffKind.DIFF_DELETE:
|
||||
classes.push(styles.diff_remove)
|
||||
break
|
||||
case DiffKind.DIFF_INSERT:
|
||||
classes.push(styles.diff_add)
|
||||
break
|
||||
case DiffKind.DIFF_REPLACE:
|
||||
classes.push(styles.diff_change)
|
||||
break
|
||||
case DiffKind.DIFF_DELETE:
|
||||
classes.push(styles.diff_remove);
|
||||
break;
|
||||
case DiffKind.DIFF_INSERT:
|
||||
classes.push(styles.diff_add);
|
||||
break;
|
||||
case DiffKind.DIFF_REPLACE:
|
||||
classes.push(styles.diff_change);
|
||||
break;
|
||||
}
|
||||
|
||||
return <div
|
||||
className={classNames(styles.cell, classes, {
|
||||
[styles.highlight]: hasLineNo && cell.instruction.line_number === selectedSourceLine,
|
||||
})}
|
||||
>
|
||||
{hasLineNo && <span className={styles.lineNumber}><button onClick={() => scrollToLineNumber(sourceEditor, cell.instruction.line_number)}>{cell.instruction.line_number}</button></span>}
|
||||
<FormatDiffText insDiff={cell} baseAddress={baseAddress} highlighter={highlighter} />
|
||||
</div>
|
||||
return (
|
||||
<div
|
||||
className={classNames(styles.cell, classes, {
|
||||
[styles.highlight]:
|
||||
hasLineNo &&
|
||||
cell.instruction.line_number === selectedSourceLine,
|
||||
})}
|
||||
>
|
||||
{hasLineNo && (
|
||||
<span className={styles.lineNumber}>
|
||||
<button
|
||||
onClick={() =>
|
||||
scrollToLineNumber(
|
||||
sourceEditor,
|
||||
cell.instruction.line_number,
|
||||
)
|
||||
}
|
||||
>
|
||||
{cell.instruction.line_number}
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
<FormatDiffText
|
||||
insDiff={cell}
|
||||
baseAddress={baseAddress}
|
||||
highlighter={highlighter}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function findSymbol(object: ObjectDiff | null, symbol_name: string): FunctionDiff | null {
|
||||
function findSymbol(
|
||||
object: ObjectDiff | null,
|
||||
symbol_name: string,
|
||||
): FunctionDiff | null {
|
||||
if (!object) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
for (const section of object.sections) {
|
||||
for (const symbol_diff of section.functions) {
|
||||
if (symbol_diff.symbol.name === symbol_name) {
|
||||
return symbol_diff
|
||||
return symbol_diff;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
export const DiffRow = memo(function DiffRow({ data, index, style }: { data: DiffListData, index: number, style: CSSProperties }) {
|
||||
const leftIns = data.left?.instructions?.[index]
|
||||
const leftSymbol = data.left?.symbol
|
||||
const rightIns = data.right?.instructions?.[index]
|
||||
const rightSymbol = data.right?.symbol
|
||||
return <li
|
||||
className={styles.row}
|
||||
style={{
|
||||
...style,
|
||||
top: `${Number.parseFloat(style.top.toString()) + PADDING_TOP}px`,
|
||||
lineHeight: `${style.height.toString()}px`,
|
||||
}}
|
||||
>
|
||||
<DiffCell cell={leftIns} baseAddress={leftSymbol?.address} highlighter={data.highlighters[0]} />
|
||||
<DiffCell cell={rightIns} baseAddress={rightSymbol?.address} highlighter={data.highlighters[1]} />
|
||||
</li>
|
||||
}, areEqual)
|
||||
export const DiffRow = memo(function DiffRow({
|
||||
data,
|
||||
index,
|
||||
style,
|
||||
}: { data: DiffListData; index: number; style: CSSProperties }) {
|
||||
const leftIns = data.left?.instructions?.[index];
|
||||
const leftSymbol = data.left?.symbol;
|
||||
const rightIns = data.right?.instructions?.[index];
|
||||
const rightSymbol = data.right?.symbol;
|
||||
return (
|
||||
<li
|
||||
className={styles.row}
|
||||
style={{
|
||||
...style,
|
||||
top: `${Number.parseFloat(style.top.toString()) + PADDING_TOP}px`,
|
||||
lineHeight: `${style.height.toString()}px`,
|
||||
}}
|
||||
>
|
||||
<DiffCell
|
||||
cell={leftIns}
|
||||
baseAddress={leftSymbol?.address}
|
||||
highlighter={data.highlighters[0]}
|
||||
/>
|
||||
<DiffCell
|
||||
cell={rightIns}
|
||||
baseAddress={rightSymbol?.address}
|
||||
highlighter={data.highlighters[1]}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
}, areEqual);
|
||||
|
||||
export type DiffListData = {
|
||||
left: FunctionDiff | undefined
|
||||
right: FunctionDiff | undefined
|
||||
itemCount: number
|
||||
highlighters: Highlighter[]
|
||||
}
|
||||
left: FunctionDiff | undefined;
|
||||
right: FunctionDiff | undefined;
|
||||
itemCount: number;
|
||||
highlighters: Highlighter[];
|
||||
};
|
||||
|
||||
export const createDiffListData = memoize((
|
||||
diff: DiffResult | null,
|
||||
diffLabel: string,
|
||||
highlighters: Highlighter[]
|
||||
): DiffListData => {
|
||||
const left = findSymbol(diff?.left, diffLabel)
|
||||
const right = findSymbol(diff?.right, diffLabel)
|
||||
const itemCount = Math.min(left?.instructions.length ?? 0, right?.instructions.length ?? 0)
|
||||
return { left, right, itemCount, highlighters }
|
||||
})
|
||||
export const createDiffListData = memoize(
|
||||
(
|
||||
diff: DiffResult | null,
|
||||
diffLabel: string,
|
||||
highlighters: Highlighter[],
|
||||
): DiffListData => {
|
||||
const left = findSymbol(diff?.left, diffLabel);
|
||||
const right = findSymbol(diff?.right, diffLabel);
|
||||
const itemCount = Math.min(
|
||||
left?.instructions.length ?? 0,
|
||||
right?.instructions.length ?? 0,
|
||||
);
|
||||
return { left, right, itemCount, highlighters };
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,57 +1,59 @@
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import styles from "./DragBar.module.scss"
|
||||
import styles from "./DragBar.module.scss";
|
||||
|
||||
export interface Props {
|
||||
pos: number
|
||||
onChange: (pos: number) => void
|
||||
pos: number;
|
||||
onChange: (pos: number) => void;
|
||||
}
|
||||
|
||||
export default function DragBar({ pos, onChange }: Props) {
|
||||
const [isActive, setIsActive] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>()
|
||||
const [isActive, setIsActive] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>();
|
||||
|
||||
useEffect(() => {
|
||||
const onMouseMove = (evt: MouseEvent) => {
|
||||
if (isActive) {
|
||||
const parent = ref.current.parentElement
|
||||
const parent = ref.current.parentElement;
|
||||
if (parent)
|
||||
onChange(evt.clientX - parent.getBoundingClientRect().x)
|
||||
onChange(evt.clientX - parent.getBoundingClientRect().x);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onTouchMove = (evt: TouchEvent) => {
|
||||
if (isActive) {
|
||||
const parent = ref.current.parentElement
|
||||
const parent = ref.current.parentElement;
|
||||
if (parent) {
|
||||
const touch = evt.touches[0]
|
||||
onChange(touch.clientX - parent.getBoundingClientRect().x)
|
||||
const touch = evt.touches[0];
|
||||
onChange(touch.clientX - parent.getBoundingClientRect().x);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onMouseUp = () => {
|
||||
setIsActive(false)
|
||||
}
|
||||
setIsActive(false);
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", onMouseMove)
|
||||
document.addEventListener("mouseup", onMouseUp)
|
||||
document.addEventListener("touchmove", onTouchMove)
|
||||
document.addEventListener("touchend", onMouseUp)
|
||||
document.addEventListener("mousemove", onMouseMove);
|
||||
document.addEventListener("mouseup", onMouseUp);
|
||||
document.addEventListener("touchmove", onTouchMove);
|
||||
document.addEventListener("touchend", onMouseUp);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", onMouseMove)
|
||||
document.removeEventListener("mouseup", onMouseUp)
|
||||
document.removeEventListener("touchmove", onTouchMove)
|
||||
document.removeEventListener("touchend", onMouseUp)
|
||||
}
|
||||
})
|
||||
document.removeEventListener("mousemove", onMouseMove);
|
||||
document.removeEventListener("mouseup", onMouseUp);
|
||||
document.removeEventListener("touchmove", onTouchMove);
|
||||
document.removeEventListener("touchend", onMouseUp);
|
||||
};
|
||||
});
|
||||
|
||||
return <div
|
||||
ref={ref}
|
||||
className={`${styles.vertical} ${isActive && styles.active}`}
|
||||
style={{ left: `${pos}px` }}
|
||||
onMouseDown={() => setIsActive(true)}
|
||||
onTouchMove={() => setIsActive(true)}
|
||||
/>
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`${styles.vertical} ${isActive && styles.active}`}
|
||||
style={{ left: `${pos}px` }}
|
||||
onMouseDown={() => setIsActive(true)}
|
||||
onTouchMove={() => setIsActive(true)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,34 +1,34 @@
|
||||
import { useState, useMemo } from "react"
|
||||
import { useState, useMemo } from "react";
|
||||
|
||||
export type Highlighter = {
|
||||
value: string | null
|
||||
setValue: (value: string | null) => void
|
||||
select: (value: string) => void
|
||||
}
|
||||
value: string | null;
|
||||
setValue: (value: string | null) => void;
|
||||
select: (value: string) => void;
|
||||
};
|
||||
|
||||
export type HighlighterContextData = {
|
||||
highlighters: Highlighter[]
|
||||
setHighlightAll: Highlighter["setValue"]
|
||||
}
|
||||
highlighters: Highlighter[];
|
||||
setHighlightAll: Highlighter["setValue"];
|
||||
};
|
||||
|
||||
export function useHighlighers(count: number): HighlighterContextData {
|
||||
const [values, setValues] = useState<string[]>(Array(count).fill(null))
|
||||
const [values, setValues] = useState<string[]>(Array(count).fill(null));
|
||||
if (values.length !== count) {
|
||||
throw new Error("Count changed")
|
||||
throw new Error("Count changed");
|
||||
}
|
||||
return useMemo(() => {
|
||||
const highlighters: Highlighter[] = []
|
||||
const highlighters: Highlighter[] = [];
|
||||
const setHighlightAll = (value: string | null) => {
|
||||
setValues(Array(count).fill(value))
|
||||
}
|
||||
setValues(Array(count).fill(value));
|
||||
};
|
||||
for (let i = 0; i < count; i++) {
|
||||
const setValue = (newValue: string | null) => {
|
||||
setValues(values => {
|
||||
const newValues = [...values]
|
||||
newValues[i] = newValue
|
||||
return newValues
|
||||
})
|
||||
}
|
||||
setValues((values) => {
|
||||
const newValues = [...values];
|
||||
newValues[i] = newValue;
|
||||
return newValues;
|
||||
});
|
||||
};
|
||||
highlighters.push({
|
||||
value: values[i],
|
||||
setValue: setValue,
|
||||
@@ -36,13 +36,13 @@ export function useHighlighers(count: number): HighlighterContextData {
|
||||
// When selecting the same value twice (double-clicking), select it
|
||||
// in all diff columns
|
||||
if (values[i] === newValue) {
|
||||
setHighlightAll(newValue)
|
||||
setHighlightAll(newValue);
|
||||
} else {
|
||||
setValue(newValue)
|
||||
setValue(newValue);
|
||||
}
|
||||
},
|
||||
})
|
||||
});
|
||||
}
|
||||
return { highlighters, setHighlightAll }
|
||||
}, [count, values])
|
||||
return { highlighters, setHighlightAll };
|
||||
}, [count, values]);
|
||||
}
|
||||
|
||||
@@ -1,22 +1,28 @@
|
||||
import { type ReactNode, useState } from "react"
|
||||
import { type ReactNode, useState } from "react";
|
||||
|
||||
import { XIcon } from "@primer/octicons-react"
|
||||
import classNames from "classnames"
|
||||
import { XIcon } from "@primer/octicons-react";
|
||||
import classNames from "classnames";
|
||||
|
||||
import styles from "./DismissableBanner.module.scss"
|
||||
import styles from "./DismissableBanner.module.scss";
|
||||
|
||||
export default function DismissableBanner({ className, children }: { className?: string, children?: ReactNode }) {
|
||||
const [isOpen, setIsOpen] = useState(true)
|
||||
export default function DismissableBanner({
|
||||
className,
|
||||
children,
|
||||
}: { className?: string; children?: ReactNode }) {
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
|
||||
if (!isOpen)
|
||||
return null
|
||||
if (!isOpen) return null;
|
||||
|
||||
return <div className={classNames(styles.container, className)}>
|
||||
<div className={styles.content}>
|
||||
{children}
|
||||
return (
|
||||
<div className={classNames(styles.container, className)}>
|
||||
<div className={styles.content}>{children}</div>
|
||||
<button
|
||||
title="Dismiss"
|
||||
className={styles.dismiss}
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
<XIcon />
|
||||
</button>
|
||||
</div>
|
||||
<button title="Dismiss" className={styles.dismiss} onClick={() => setIsOpen(false)}>
|
||||
<XIcon />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,45 +1,54 @@
|
||||
import { type CSSProperties, type MutableRefObject, useCallback, useEffect, useRef } from "react"
|
||||
import {
|
||||
type CSSProperties,
|
||||
type MutableRefObject,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
} from "react";
|
||||
|
||||
import { type Extension, EditorState } from "@codemirror/state"
|
||||
import { EditorView } from "@codemirror/view"
|
||||
import classNames from "classnames"
|
||||
import { useDebouncedCallback } from "use-debounce"
|
||||
import { type Extension, EditorState } from "@codemirror/state";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import classNames from "classnames";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
|
||||
import { useSize } from "@/lib/hooks"
|
||||
import { useCodeFontSize } from "@/lib/settings"
|
||||
import { useSize } from "@/lib/hooks";
|
||||
import { useCodeFontSize } from "@/lib/settings";
|
||||
|
||||
import styles from "./CodeMirror.module.scss"
|
||||
import styles from "./CodeMirror.module.scss";
|
||||
|
||||
// useDebouncedCallback is a bit dodgy when both leading and trailing are true, so here's a reimplementation
|
||||
function useLeadingTrailingDebounceCallback(callback: () => void, delay: number) {
|
||||
const timeout = useRef<any>()
|
||||
function useLeadingTrailingDebounceCallback(
|
||||
callback: () => void,
|
||||
delay: number,
|
||||
) {
|
||||
const timeout = useRef<any>();
|
||||
|
||||
return useCallback(() => {
|
||||
if (timeout.current) {
|
||||
clearTimeout(timeout.current)
|
||||
clearTimeout(timeout.current);
|
||||
} else {
|
||||
// Leading
|
||||
callback()
|
||||
callback();
|
||||
}
|
||||
|
||||
timeout.current = setTimeout(() => {
|
||||
timeout.current = undefined
|
||||
timeout.current = undefined;
|
||||
|
||||
// Trailing
|
||||
callback()
|
||||
}, delay)
|
||||
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
callback();
|
||||
}, delay);
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}
|
||||
|
||||
export interface Props {
|
||||
value: string
|
||||
valueVersion: number
|
||||
onChange?: (value: string) => void
|
||||
onHoveredLineChange?: (value: number | null) => void
|
||||
onSelectedLineChange?: (value: number) => void
|
||||
className?: string
|
||||
viewRef?: MutableRefObject<EditorView | null>
|
||||
extensions: Extension // const
|
||||
value: string;
|
||||
valueVersion: number;
|
||||
onChange?: (value: string) => void;
|
||||
onHoveredLineChange?: (value: number | null) => void;
|
||||
onSelectedLineChange?: (value: number) => void;
|
||||
className?: string;
|
||||
viewRef?: MutableRefObject<EditorView | null>;
|
||||
extensions: Extension; // const
|
||||
}
|
||||
|
||||
export default function CodeMirror({
|
||||
@@ -52,34 +61,34 @@ export default function CodeMirror({
|
||||
viewRef: viewRefProp,
|
||||
extensions,
|
||||
}: Props) {
|
||||
const { ref: el, width } = useSize<HTMLDivElement>()
|
||||
const { ref: el, width } = useSize<HTMLDivElement>();
|
||||
|
||||
const valueRef = useRef(value)
|
||||
valueRef.current = value
|
||||
const valueRef = useRef(value);
|
||||
valueRef.current = value;
|
||||
|
||||
const onChangeRef = useRef(onChange)
|
||||
onChangeRef.current = onChange
|
||||
const onChangeRef = useRef(onChange);
|
||||
onChangeRef.current = onChange;
|
||||
|
||||
const viewRef = useRef<EditorView>()
|
||||
const viewRef = useRef<EditorView>();
|
||||
|
||||
const extensionsRef = useRef(extensions)
|
||||
extensionsRef.current = extensions
|
||||
const extensionsRef = useRef(extensions);
|
||||
extensionsRef.current = extensions;
|
||||
|
||||
const selectedLineRef = useRef<number>()
|
||||
const hoveredLineRef = useRef<number>()
|
||||
const selectedLineRef = useRef<number>();
|
||||
const hoveredLineRef = useRef<number>();
|
||||
|
||||
const onHoveredLineChangeRef = useRef(onHoveredLineChange)
|
||||
onHoveredLineChangeRef.current = onHoveredLineChange
|
||||
const onHoveredLineChangeRef = useRef(onHoveredLineChange);
|
||||
onHoveredLineChangeRef.current = onHoveredLineChange;
|
||||
|
||||
const onSelectedLineChangeRef = useRef(onSelectedLineChange)
|
||||
onSelectedLineChangeRef.current = onSelectedLineChange
|
||||
const onSelectedLineChangeRef = useRef(onSelectedLineChange);
|
||||
onSelectedLineChangeRef.current = onSelectedLineChange;
|
||||
|
||||
const [fontSize] = useCodeFontSize()
|
||||
const [fontSize] = useCodeFontSize();
|
||||
|
||||
// Defer calls to onChange to avoid excessive re-renders
|
||||
const propagateValue = useLeadingTrailingDebounceCallback(() => {
|
||||
onChangeRef.current?.(viewRef.current.state.doc.toString())
|
||||
}, 100)
|
||||
onChangeRef.current?.(viewRef.current.state.doc.toString());
|
||||
}, 100);
|
||||
|
||||
// Initial view creation
|
||||
useEffect(() => {
|
||||
@@ -87,47 +96,49 @@ export default function CodeMirror({
|
||||
state: EditorState.create({
|
||||
doc: valueRef.current,
|
||||
extensions: [
|
||||
EditorState.transactionExtender.of(({ docChanged, newDoc, newSelection }) => {
|
||||
// value / onChange
|
||||
if (docChanged) {
|
||||
valueRef.current = newDoc.toString()
|
||||
propagateValue()
|
||||
}
|
||||
EditorState.transactionExtender.of(
|
||||
({ docChanged, newDoc, newSelection }) => {
|
||||
// value / onChange
|
||||
if (docChanged) {
|
||||
valueRef.current = newDoc.toString();
|
||||
propagateValue();
|
||||
}
|
||||
|
||||
// selectedSourceLine
|
||||
const line = newDoc.lineAt(newSelection.main.from).number
|
||||
if (hoveredLineRef.current !== line) {
|
||||
hoveredLineRef.current = line
|
||||
requestAnimationFrame(() => {
|
||||
onSelectedLineChangeRef.current?.(line)
|
||||
})
|
||||
}
|
||||
// selectedSourceLine
|
||||
const line = newDoc.lineAt(
|
||||
newSelection.main.from,
|
||||
).number;
|
||||
if (hoveredLineRef.current !== line) {
|
||||
hoveredLineRef.current = line;
|
||||
requestAnimationFrame(() => {
|
||||
onSelectedLineChangeRef.current?.(line);
|
||||
});
|
||||
}
|
||||
|
||||
return null
|
||||
}),
|
||||
return null;
|
||||
},
|
||||
),
|
||||
|
||||
extensionsRef.current,
|
||||
],
|
||||
}),
|
||||
parent: el.current,
|
||||
})
|
||||
});
|
||||
|
||||
if (viewRefProp)
|
||||
viewRefProp.current = viewRef.current
|
||||
if (viewRefProp) viewRefProp.current = viewRef.current;
|
||||
|
||||
return () => {
|
||||
viewRef.current.destroy()
|
||||
viewRef.current = null
|
||||
if (viewRefProp)
|
||||
viewRefProp.current = null
|
||||
}
|
||||
}, [el, propagateValue, viewRefProp])
|
||||
viewRef.current.destroy();
|
||||
viewRef.current = null;
|
||||
if (viewRefProp) viewRefProp.current = null;
|
||||
};
|
||||
}, [el, propagateValue, viewRefProp]);
|
||||
|
||||
// Replace doc when `valueVersion` prop changes
|
||||
useEffect(() => {
|
||||
const view = viewRef.current
|
||||
const view = viewRef.current;
|
||||
if (view) {
|
||||
const prevValue = view.state.doc.toString()
|
||||
const prevValue = view.state.doc.toString();
|
||||
|
||||
if (prevValue !== value) {
|
||||
view.dispatch(
|
||||
@@ -137,42 +148,47 @@ export default function CodeMirror({
|
||||
to: prevValue.length,
|
||||
insert: value,
|
||||
},
|
||||
})
|
||||
)
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}, [valueVersion]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [valueVersion]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const debouncedOnMouseMove = useDebouncedCallback(
|
||||
event => {
|
||||
if (!onHoveredLineChangeRef.current)
|
||||
return
|
||||
(event) => {
|
||||
if (!onHoveredLineChangeRef.current) return;
|
||||
|
||||
const view = viewRef.current
|
||||
let newLine: number | null = null
|
||||
const view = viewRef.current;
|
||||
let newLine: number | null = null;
|
||||
if (view) {
|
||||
const line = view.state.doc.lineAt(view.posAtCoords({ x: event.clientX, y: event.clientY })).number
|
||||
const line = view.state.doc.lineAt(
|
||||
view.posAtCoords({ x: event.clientX, y: event.clientY }),
|
||||
).number;
|
||||
if (line) {
|
||||
newLine = line
|
||||
newLine = line;
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedLineRef.current !== newLine) {
|
||||
selectedLineRef.current = newLine
|
||||
onHoveredLineChangeRef.current?.(newLine)
|
||||
selectedLineRef.current = newLine;
|
||||
onHoveredLineChangeRef.current?.(newLine);
|
||||
}
|
||||
},
|
||||
100,
|
||||
{ leading: true, trailing: true },
|
||||
)
|
||||
);
|
||||
|
||||
return <div
|
||||
ref={el}
|
||||
onMouseMove={debouncedOnMouseMove}
|
||||
className={classNames(styles.container, className)}
|
||||
style={{
|
||||
"--cm-font-size": `${fontSize}px`,
|
||||
"--cm-container-width": `${width}px`,
|
||||
} as CSSProperties}
|
||||
/>
|
||||
return (
|
||||
<div
|
||||
ref={el}
|
||||
onMouseMove={debouncedOnMouseMove}
|
||||
className={classNames(styles.container, className)}
|
||||
style={
|
||||
{
|
||||
"--cm-font-size": `${fontSize}px`,
|
||||
"--cm-container-width": `${width}px`,
|
||||
} as CSSProperties
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,40 +1,42 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import { Component, type ReactNode } from "react"
|
||||
import { Component, type ReactNode } from "react";
|
||||
|
||||
interface State {
|
||||
error?: unknown
|
||||
error?: unknown;
|
||||
}
|
||||
|
||||
export interface Props {
|
||||
children?: ReactNode
|
||||
forceError?: boolean
|
||||
fallback?: (state: State) => ReactNode
|
||||
onError?: (error: unknown, errorInfo: any) => void
|
||||
children?: ReactNode;
|
||||
forceError?: boolean;
|
||||
fallback?: (state: State) => ReactNode;
|
||||
onError?: (error: unknown, errorInfo: any) => void;
|
||||
}
|
||||
|
||||
export default class ErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props)
|
||||
this.state = { error: undefined }
|
||||
super(props);
|
||||
this.state = { error: undefined };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: unknown) {
|
||||
// Update state so the next render will show the fallback UI.
|
||||
return { error }
|
||||
return { error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: unknown, errorInfo: unknown) {
|
||||
console.error("Error boundary caught an error:", error, errorInfo)
|
||||
this.props.onError?.(error, errorInfo)
|
||||
console.error("Error boundary caught an error:", error, errorInfo);
|
||||
this.props.onError?.(error, errorInfo);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.error || this.props.forceError) {
|
||||
const fallback = this.props.fallback ? this.props.fallback(this.state) : null
|
||||
return fallback
|
||||
const fallback = this.props.fallback
|
||||
? this.props.fallback(this.state)
|
||||
: null;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return this.props.children || null
|
||||
return this.props.children || null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,68 +1,72 @@
|
||||
import Link from "next/link"
|
||||
import Link from "next/link";
|
||||
|
||||
import { MarkGithubIcon } from "@primer/octicons-react"
|
||||
import { MarkGithubIcon } from "@primer/octicons-react";
|
||||
|
||||
import DiscordIcon from "./discord.svg"
|
||||
import GhostButton from "./GhostButton"
|
||||
import Logotype from "./Logotype"
|
||||
import DiscordIcon from "./discord.svg";
|
||||
import GhostButton from "./GhostButton";
|
||||
import Logotype from "./Logotype";
|
||||
|
||||
function Separator() {
|
||||
return <div className="hidden h-4 w-px bg-gray-6 sm:inline-block" />
|
||||
return <div className="hidden h-4 w-px bg-gray-6 sm:inline-block" />;
|
||||
}
|
||||
|
||||
const commitHash = process.env.NEXT_PUBLIC_COMMIT_HASH
|
||||
const commitHash = process.env.NEXT_PUBLIC_COMMIT_HASH;
|
||||
|
||||
export default function Footer() {
|
||||
return <>
|
||||
<div className="grow" />
|
||||
<footer className="mx-auto mt-16 w-full px-4 sm:px-6 lg:px-8">
|
||||
<div className="border-gray-6 border-t py-10">
|
||||
<div className="flex items-center justify-center">
|
||||
<Link href="/" >
|
||||
<Logotype />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-col items-center justify-center gap-1 sm:flex-row sm:gap-2">
|
||||
<GhostButton href="/privacy">
|
||||
Privacy policy
|
||||
</GhostButton>
|
||||
<Separator />
|
||||
<GhostButton href="/credits">
|
||||
Credits
|
||||
</GhostButton>
|
||||
<Separator />
|
||||
<GhostButton href="/faq">
|
||||
FAQ
|
||||
</GhostButton>
|
||||
<Separator />
|
||||
<GhostButton href="https://github.com/decompme/decomp.me" className="flex items-center gap-1.5">
|
||||
<MarkGithubIcon className="size-4" />
|
||||
Source code
|
||||
</GhostButton>
|
||||
<Separator />
|
||||
<GhostButton href="https://discord.gg/sutqNShRRs" className="flex items-center gap-1.5">
|
||||
<DiscordIcon className="size-4" />
|
||||
Chat
|
||||
</GhostButton>
|
||||
<Separator />
|
||||
<GhostButton href="https://status.decomp.me">
|
||||
Status
|
||||
</GhostButton>
|
||||
<Separator />
|
||||
<GhostButton href="https://stats.decomp.me/decomp.me">
|
||||
Stats
|
||||
</GhostButton>
|
||||
</div>
|
||||
return (
|
||||
<>
|
||||
<div className="grow" />
|
||||
<footer className="mx-auto mt-16 w-full px-4 sm:px-6 lg:px-8">
|
||||
<div className="border-gray-6 border-t py-10">
|
||||
<div className="flex items-center justify-center">
|
||||
<Link href="/">
|
||||
<Logotype />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-col items-center justify-center gap-1 sm:flex-row sm:gap-2">
|
||||
<GhostButton href="/privacy">
|
||||
Privacy policy
|
||||
</GhostButton>
|
||||
<Separator />
|
||||
<GhostButton href="/credits">Credits</GhostButton>
|
||||
<Separator />
|
||||
<GhostButton href="/faq">FAQ</GhostButton>
|
||||
<Separator />
|
||||
<GhostButton
|
||||
href="https://github.com/decompme/decomp.me"
|
||||
className="flex items-center gap-1.5"
|
||||
>
|
||||
<MarkGithubIcon className="size-4" />
|
||||
Source code
|
||||
</GhostButton>
|
||||
<Separator />
|
||||
<GhostButton
|
||||
href="https://discord.gg/sutqNShRRs"
|
||||
className="flex items-center gap-1.5"
|
||||
>
|
||||
<DiscordIcon className="size-4" />
|
||||
Chat
|
||||
</GhostButton>
|
||||
<Separator />
|
||||
<GhostButton href="https://status.decomp.me">
|
||||
Status
|
||||
</GhostButton>
|
||||
<Separator />
|
||||
<GhostButton href="https://stats.decomp.me/decomp.me">
|
||||
Stats
|
||||
</GhostButton>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex items-center justify-center text-[#808080] text-xs">
|
||||
<Link
|
||||
href={`https://github.com/decompme/decomp.me/commit/${commitHash}`}
|
||||
title="Commit hash">
|
||||
|
||||
{(commitHash?.slice(0, 7)) || "unknown"}
|
||||
</Link>
|
||||
<div className="mt-2 flex items-center justify-center text-[#808080] text-xs">
|
||||
<Link
|
||||
href={`https://github.com/decompme/decomp.me/commit/${commitHash}`}
|
||||
title="Commit hash"
|
||||
>
|
||||
{commitHash?.slice(0, 7) || "unknown"}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</>
|
||||
</footer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,36 +1,44 @@
|
||||
import type { ReactNode } from "react"
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import Link from "next/link"
|
||||
import Link from "next/link";
|
||||
|
||||
import classNames from "classnames"
|
||||
import classNames from "classnames";
|
||||
|
||||
export type Props = {
|
||||
href?: string
|
||||
onClick?: () => void
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}
|
||||
href?: string;
|
||||
onClick?: () => void;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export default function GhostButton({ children, href, onClick, className }: Props) {
|
||||
const isClickable = !!(href || onClick)
|
||||
export default function GhostButton({
|
||||
children,
|
||||
href,
|
||||
onClick,
|
||||
className,
|
||||
}: Props) {
|
||||
const isClickable = !!(href || onClick);
|
||||
const cn = classNames(className, {
|
||||
"rounded bg-transparent px-2 py-1 text-sm whitespace-nowrap inline-block": true,
|
||||
"transition-colors hover:bg-gray-3 cursor-pointer active:translate-y-px hover:text-gray-12": isClickable,
|
||||
})
|
||||
"transition-colors hover:bg-gray-3 cursor-pointer active:translate-y-px hover:text-gray-12":
|
||||
isClickable,
|
||||
});
|
||||
|
||||
if (href) {
|
||||
return <Link href={href} className={cn} onClick={onClick}>
|
||||
{children}
|
||||
</Link>
|
||||
return (
|
||||
<Link href={href} className={cn} onClick={onClick}>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
if (onClick) {
|
||||
return <button className={cn} onClick={onClick}>
|
||||
{children}
|
||||
</button>
|
||||
return (
|
||||
<button className={cn} onClick={onClick}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return <div className={cn}>
|
||||
{children}
|
||||
</div>
|
||||
return <div className={cn}>{children}</div>;
|
||||
}
|
||||
|
||||
@@ -1,30 +1,40 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import { MarkGithubIcon } from "@primer/octicons-react"
|
||||
import { MarkGithubIcon } from "@primer/octicons-react";
|
||||
|
||||
import { isAnonUser, useThisUser } from "@/lib/api"
|
||||
import { isGitHubLoginSupported, showGitHubLoginWindow } from "@/lib/oauth"
|
||||
import { isAnonUser, useThisUser } from "@/lib/api";
|
||||
import { isGitHubLoginSupported, showGitHubLoginWindow } from "@/lib/oauth";
|
||||
|
||||
import Button from "./Button"
|
||||
import Button from "./Button";
|
||||
|
||||
const DEFAULT_SCOPE_STR = ""
|
||||
const DEFAULT_SCOPE_STR = "";
|
||||
|
||||
export default function GitHubLoginButton({ label, className }: { label?: string, className?: string }) {
|
||||
const user = useThisUser()
|
||||
export default function GitHubLoginButton({
|
||||
label,
|
||||
className,
|
||||
}: { label?: string; className?: string }) {
|
||||
const user = useThisUser();
|
||||
|
||||
if (user && !isAnonUser(user)) {
|
||||
// We're already logged in
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isGitHubLoginSupported()) {
|
||||
return <Button className={className} onClick={() => showGitHubLoginWindow(DEFAULT_SCOPE_STR)}>
|
||||
<MarkGithubIcon size={16} /> {label ?? "Sign in with GitHub"}
|
||||
</Button>
|
||||
return (
|
||||
<Button
|
||||
className={className}
|
||||
onClick={() => showGitHubLoginWindow(DEFAULT_SCOPE_STR)}
|
||||
>
|
||||
<MarkGithubIcon size={16} /> {label ?? "Sign in with GitHub"}
|
||||
</Button>
|
||||
);
|
||||
} else {
|
||||
// The backend is not configured to support GitHub login
|
||||
return <Button className={className} onClick={() => {}} disabled>
|
||||
<MarkGithubIcon size={16} /> Unavailable
|
||||
</Button>
|
||||
return (
|
||||
<Button className={className} onClick={() => {}} disabled>
|
||||
<MarkGithubIcon size={16} /> Unavailable
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import Frog from "./Nav/frog.svg"
|
||||
import Frog from "./Nav/frog.svg";
|
||||
|
||||
export default function Logotype() {
|
||||
return <div className="inline-flex items-center space-x-2" aria-label="decomp.me logo">
|
||||
<Frog className="size-7" aria-label="Purple frog" />
|
||||
<span className="font-semibold text-xl leading-6 tracking-tight">decomp.me</span>
|
||||
</div>
|
||||
return (
|
||||
<div
|
||||
className="inline-flex items-center space-x-2"
|
||||
aria-label="decomp.me logo"
|
||||
>
|
||||
<Frog className="size-7" aria-label="Purple frog" />
|
||||
<span className="font-semibold text-xl leading-6 tracking-tight">
|
||||
decomp.me
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
import { useState } from "react"
|
||||
import { useState } from "react";
|
||||
|
||||
import Image from "next/image"
|
||||
import Image from "next/image";
|
||||
|
||||
import classNames from "classnames"
|
||||
import { useLayer } from "react-laag"
|
||||
import classNames from "classnames";
|
||||
import { useLayer } from "react-laag";
|
||||
|
||||
import * as api from "@/lib/api"
|
||||
import { userAvatarUrl } from "@/lib/api/urls"
|
||||
import * as api from "@/lib/api";
|
||||
import { userAvatarUrl } from "@/lib/api/urls";
|
||||
|
||||
import GitHubLoginButton from "../GitHubLoginButton"
|
||||
import VerticalMenu from "../VerticalMenu"
|
||||
import GitHubLoginButton from "../GitHubLoginButton";
|
||||
import VerticalMenu from "../VerticalMenu";
|
||||
|
||||
import styles from "./LoginState.module.scss"
|
||||
import UserMenu from "./UserMenuItems"
|
||||
import styles from "./LoginState.module.scss";
|
||||
import UserMenu from "./UserMenuItems";
|
||||
|
||||
export default function LoginState({ className }: { className?: string }) {
|
||||
const user = api.useThisUser()
|
||||
const [isUserMenuOpen, setUserMenuOpen] = useState(false)
|
||||
const user = api.useThisUser();
|
||||
const [isUserMenuOpen, setUserMenuOpen] = useState(false);
|
||||
|
||||
const { renderLayer, triggerProps, layerProps } = useLayer({
|
||||
isOpen: isUserMenuOpen,
|
||||
@@ -25,35 +25,44 @@ export default function LoginState({ className }: { className?: string }) {
|
||||
auto: false,
|
||||
placement: "bottom-end",
|
||||
triggerOffset: 4,
|
||||
})
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
// Loading...
|
||||
return <div />
|
||||
return <div />;
|
||||
}
|
||||
|
||||
if (api.isAnonUser(user)) {
|
||||
return <GitHubLoginButton label="Sign in" />
|
||||
return <GitHubLoginButton label="Sign in" />;
|
||||
}
|
||||
|
||||
return <button
|
||||
className={classNames(styles.user, className)}
|
||||
onClick={() => setUserMenuOpen(!isUserMenuOpen)}
|
||||
{...triggerProps}
|
||||
>
|
||||
<Image
|
||||
className={styles.avatar}
|
||||
src={userAvatarUrl(user)}
|
||||
alt="Account menu"
|
||||
width={28}
|
||||
height={28}
|
||||
sizes="28px"
|
||||
priority
|
||||
/>
|
||||
{renderLayer(<div {...layerProps}>
|
||||
{isUserMenuOpen && <VerticalMenu open={isUserMenuOpen} setOpen={setUserMenuOpen}>
|
||||
<UserMenu />
|
||||
</VerticalMenu>}
|
||||
</div>)}
|
||||
</button>
|
||||
return (
|
||||
<button
|
||||
className={classNames(styles.user, className)}
|
||||
onClick={() => setUserMenuOpen(!isUserMenuOpen)}
|
||||
{...triggerProps}
|
||||
>
|
||||
<Image
|
||||
className={styles.avatar}
|
||||
src={userAvatarUrl(user)}
|
||||
alt="Account menu"
|
||||
width={28}
|
||||
height={28}
|
||||
sizes="28px"
|
||||
priority
|
||||
/>
|
||||
{renderLayer(
|
||||
<div {...layerProps}>
|
||||
{isUserMenuOpen && (
|
||||
<VerticalMenu
|
||||
open={isUserMenuOpen}
|
||||
setOpen={setUserMenuOpen}
|
||||
>
|
||||
<UserMenu />
|
||||
</VerticalMenu>
|
||||
)}
|
||||
</div>,
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,45 +1,45 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import { useEffect, useReducer, type ReactNode } from "react"
|
||||
import { useEffect, useReducer, type ReactNode } from "react";
|
||||
|
||||
import Link from "next/link"
|
||||
import { useRouter } from "next/navigation"
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { ThreeBarsIcon, XIcon } from "@primer/octicons-react"
|
||||
import classNames from "classnames"
|
||||
import { ThreeBarsIcon, XIcon } from "@primer/octicons-react";
|
||||
import classNames from "classnames";
|
||||
|
||||
import GhostButton from "../GhostButton"
|
||||
import Logotype from "../Logotype"
|
||||
import GhostButton from "../GhostButton";
|
||||
import Logotype from "../Logotype";
|
||||
|
||||
import LoginState from "./LoginState"
|
||||
import styles from "./Nav.module.scss"
|
||||
import Search from "./Search"
|
||||
import LoginState from "./LoginState";
|
||||
import styles from "./Nav.module.scss";
|
||||
import Search from "./Search";
|
||||
|
||||
export interface Props {
|
||||
children?: ReactNode
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export default function Nav({ children }: Props) {
|
||||
const [isOpen, toggleOpen] = useReducer(isOpen => !isOpen, false)
|
||||
const toggleLabel = `${isOpen ? "Close" : "Open"} Global Navigation Menu`
|
||||
const router = useRouter()
|
||||
const [isOpen, toggleOpen] = useReducer((isOpen) => !isOpen, false);
|
||||
const toggleLabel = `${isOpen ? "Close" : "Open"} Global Navigation Menu`;
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
const onkeydown = (evt: KeyboardEvent) => {
|
||||
if (evt.key === "Escape") {
|
||||
toggleOpen()
|
||||
document.getElementById("navtoggle").focus()
|
||||
evt.preventDefault()
|
||||
toggleOpen();
|
||||
document.getElementById("navtoggle").focus();
|
||||
evt.preventDefault();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.body.addEventListener("keydown", onkeydown)
|
||||
document.body.addEventListener("keydown", onkeydown);
|
||||
return () => {
|
||||
document.body.removeEventListener("keydown", onkeydown)
|
||||
}
|
||||
document.body.removeEventListener("keydown", onkeydown);
|
||||
};
|
||||
}
|
||||
}, [isOpen, router])
|
||||
}, [isOpen, router]);
|
||||
|
||||
return (
|
||||
<nav
|
||||
@@ -59,35 +59,47 @@ export default function Nav({ children }: Props) {
|
||||
aria-label={toggleLabel}
|
||||
aria-expanded={isOpen}
|
||||
>
|
||||
{isOpen ? <XIcon size={24} /> : <ThreeBarsIcon size={18} />}
|
||||
{isOpen ? (
|
||||
<XIcon size={24} />
|
||||
) : (
|
||||
<ThreeBarsIcon size={18} />
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
<li className={styles.headerItemSiteLogo}>
|
||||
<Link href="/" className="transition-colors hover:text-gray-12 active:translate-y-px">
|
||||
<Link
|
||||
href="/"
|
||||
className="transition-colors hover:text-gray-12 active:translate-y-px"
|
||||
>
|
||||
<Logotype />
|
||||
</Link>
|
||||
</li>
|
||||
<li className={styles.headerItemLoginState}>
|
||||
<LoginState />
|
||||
</li>
|
||||
{children
|
||||
? <li className={styles.customchildren}>{children}</li>
|
||||
: <li className={styles.desktopLinks}>
|
||||
{children ? (
|
||||
<li className={styles.customchildren}>{children}</li>
|
||||
) : (
|
||||
<li className={styles.desktopLinks}>
|
||||
<ul className="flex w-full gap-2 text-sm">
|
||||
<li className="ml-4">
|
||||
<Search />
|
||||
</li>
|
||||
<div className="grow" />
|
||||
<li>
|
||||
<GhostButton href="/new">New scratch</GhostButton>
|
||||
<GhostButton href="/new">
|
||||
New scratch
|
||||
</GhostButton>
|
||||
</li>
|
||||
<div className="h-4 w-px bg-gray-6" />
|
||||
<li>
|
||||
<GhostButton href="/settings">Settings</GhostButton>
|
||||
<GhostButton href="/settings">
|
||||
Settings
|
||||
</GhostButton>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
}
|
||||
)}
|
||||
</ul>
|
||||
<div className={classNames(styles.menu, "bg-gray-1")}>
|
||||
<div className={styles.searchContainer}>
|
||||
@@ -100,16 +112,22 @@ export default function Nav({ children }: Props) {
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link onClick={toggleOpen} href="/">Dashboard</Link>
|
||||
<Link onClick={toggleOpen} href="/">
|
||||
Dashboard
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link onClick={toggleOpen} href="/new">New scratch</Link>
|
||||
<Link onClick={toggleOpen} href="/new">
|
||||
New scratch
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link onClick={toggleOpen} href="/settings">Settings</Link>
|
||||
<Link onClick={toggleOpen} href="/settings">
|
||||
Settings
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,77 +1,79 @@
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import Image from "next/image"
|
||||
import { useRouter } from "next/navigation"
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { SearchIcon } from "@primer/octicons-react"
|
||||
import classNames from "classnames"
|
||||
import { useCombobox } from "downshift"
|
||||
import { useLayer } from "react-laag"
|
||||
import { SearchIcon } from "@primer/octicons-react";
|
||||
import classNames from "classnames";
|
||||
import { useCombobox } from "downshift";
|
||||
import { useLayer } from "react-laag";
|
||||
|
||||
import * as api from "@/lib/api"
|
||||
import { scratchUrl, userAvatarUrl } from "@/lib/api/urls"
|
||||
import * as api from "@/lib/api";
|
||||
import { scratchUrl, userAvatarUrl } from "@/lib/api/urls";
|
||||
|
||||
import LoadingSpinner from "../loading.svg"
|
||||
import PlatformLink from "../PlatformLink"
|
||||
import AnonymousFrogAvatar from "../user/AnonymousFrog"
|
||||
import verticalMenuStyles from "../VerticalMenu.module.scss" // eslint-disable-line css-modules/no-unused-class
|
||||
import LoadingSpinner from "../loading.svg";
|
||||
import PlatformLink from "../PlatformLink";
|
||||
import AnonymousFrogAvatar from "../user/AnonymousFrog";
|
||||
import verticalMenuStyles from "../VerticalMenu.module.scss"; // eslint-disable-line css-modules/no-unused-class
|
||||
|
||||
import styles from "./Search.module.scss"
|
||||
import styles from "./Search.module.scss";
|
||||
|
||||
function MountedSearch({ className }: { className?: string }) {
|
||||
const [query, setQuery] = useState("")
|
||||
const [isFocused, setIsFocused] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [debouncedTimeout, setDebouncedTimeout] = useState<any>()
|
||||
const [searchItems, setSearchItems] = useState<api.TerseScratch[]>([])
|
||||
const [query, setQuery] = useState("");
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [debouncedTimeout, setDebouncedTimeout] = useState<any>();
|
||||
const [searchItems, setSearchItems] = useState<api.TerseScratch[]>([]);
|
||||
|
||||
const items = query.length > 0 ? searchItems : []
|
||||
const items = query.length > 0 ? searchItems : [];
|
||||
|
||||
const close = () => {
|
||||
console.info("<Search> close")
|
||||
setInputValue("")
|
||||
setQuery("")
|
||||
setIsFocused(false)
|
||||
}
|
||||
console.info("<Search> close");
|
||||
setInputValue("");
|
||||
setQuery("");
|
||||
setIsFocused(false);
|
||||
};
|
||||
|
||||
const {
|
||||
isOpen,
|
||||
getMenuProps,
|
||||
getInputProps,
|
||||
getItemProps,
|
||||
setInputValue,
|
||||
} = useCombobox({
|
||||
items,
|
||||
isOpen: (isFocused || !!query) && query.length > 0 && !(isLoading && items.length === 0),
|
||||
itemToString(item) {
|
||||
return item.name
|
||||
},
|
||||
onInputValueChange({ inputValue }) {
|
||||
clearTimeout(debouncedTimeout)
|
||||
setQuery(inputValue)
|
||||
const { isOpen, getMenuProps, getInputProps, getItemProps, setInputValue } =
|
||||
useCombobox({
|
||||
items,
|
||||
isOpen:
|
||||
(isFocused || !!query) &&
|
||||
query.length > 0 &&
|
||||
!(isLoading && items.length === 0),
|
||||
itemToString(item) {
|
||||
return item.name;
|
||||
},
|
||||
onInputValueChange({ inputValue }) {
|
||||
clearTimeout(debouncedTimeout);
|
||||
setQuery(inputValue);
|
||||
|
||||
if (inputValue.length === 0) {
|
||||
setSearchItems([])
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
if (inputValue.length === 0) {
|
||||
setSearchItems([]);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Use a timeout to avoid spamming the API with requests
|
||||
setIsLoading(true)
|
||||
setDebouncedTimeout(setTimeout(async () => {
|
||||
const resp = await api.get(`/scratch?search=${inputValue}&page_size=5`)
|
||||
setSearchItems(resp.results)
|
||||
setIsLoading(false)
|
||||
}, 200))
|
||||
},
|
||||
onSelectedItemChange({ selectedItem }) {
|
||||
if (selectedItem) {
|
||||
console.info("<Search> onSelectedItemChange")
|
||||
close()
|
||||
router.push(scratchUrl(selectedItem))
|
||||
}
|
||||
},
|
||||
})
|
||||
// Use a timeout to avoid spamming the API with requests
|
||||
setIsLoading(true);
|
||||
setDebouncedTimeout(
|
||||
setTimeout(async () => {
|
||||
const resp = await api.get(
|
||||
`/scratch?search=${inputValue}&page_size=5`,
|
||||
);
|
||||
setSearchItems(resp.results);
|
||||
setIsLoading(false);
|
||||
}, 200),
|
||||
);
|
||||
},
|
||||
onSelectedItemChange({ selectedItem }) {
|
||||
if (selectedItem) {
|
||||
console.info("<Search> onSelectedItemChange");
|
||||
close();
|
||||
router.push(scratchUrl(selectedItem));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const { renderLayer, triggerProps, layerProps, triggerBounds } = useLayer({
|
||||
isOpen,
|
||||
@@ -83,112 +85,140 @@ function MountedSearch({ className }: { className?: string }) {
|
||||
triggerOffset: 0,
|
||||
containerOffset: 16,
|
||||
onOutsideClick() {
|
||||
console.info("<Search> onOutsideClick")
|
||||
close()
|
||||
console.info("<Search> onOutsideClick");
|
||||
close();
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const router = useRouter()
|
||||
const router = useRouter();
|
||||
|
||||
const lastWidthRef = useRef(0)
|
||||
const lastWidthRef = useRef(0);
|
||||
if (triggerBounds) {
|
||||
lastWidthRef.current = triggerBounds.width
|
||||
lastWidthRef.current = triggerBounds.width;
|
||||
}
|
||||
|
||||
return <div
|
||||
className={classNames(styles.container, className)}
|
||||
onKeyDown={evt => {
|
||||
if (evt.key === "Enter") {
|
||||
evt.stopPropagation()
|
||||
evt.preventDefault()
|
||||
return (
|
||||
<div
|
||||
className={classNames(styles.container, className)}
|
||||
onKeyDown={(evt) => {
|
||||
if (evt.key === "Enter") {
|
||||
evt.stopPropagation();
|
||||
evt.preventDefault();
|
||||
|
||||
if (searchItems.length > 0) {
|
||||
console.info("<Search> Enter pressed")
|
||||
close()
|
||||
router.push(scratchUrl(searchItems[0]))
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SearchIcon className={styles.icon} />
|
||||
<input
|
||||
{...getInputProps(triggerProps)}
|
||||
className={classNames(styles.input, {
|
||||
[styles.isOpen]: isOpen,
|
||||
"rounded-md bg-transparent text-sm placeholder-current transition-colors hover:bg-gray-4 focus:bg-gray-5 focus:placeholder-gray-11": true,
|
||||
})}
|
||||
type="text"
|
||||
placeholder="Search scratches"
|
||||
spellCheck={false}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onClick={() => setIsFocused(true)}
|
||||
/>
|
||||
{isLoading && isFocused && <LoadingSpinner className={styles.loadingIcon} />}
|
||||
{renderLayer(
|
||||
<ul
|
||||
{...getMenuProps(layerProps)}
|
||||
className={classNames(verticalMenuStyles.menu, styles.results, {
|
||||
[styles.isOpen]: isOpen,
|
||||
})}
|
||||
style={{
|
||||
width: lastWidthRef.current,
|
||||
...layerProps.style,
|
||||
}}
|
||||
>
|
||||
{items.map((scratch, index) => {
|
||||
const props = getItemProps({ item: scratch, index })
|
||||
const oldOnClick = props.onClick
|
||||
props.onClick = evt => {
|
||||
evt.preventDefault() // Don't visit the link
|
||||
return oldOnClick(evt)
|
||||
if (searchItems.length > 0) {
|
||||
console.info("<Search> Enter pressed");
|
||||
close();
|
||||
router.push(scratchUrl(searchItems[0]));
|
||||
}
|
||||
|
||||
return <li
|
||||
key={scratchUrl(scratch)}
|
||||
{...props}
|
||||
>
|
||||
<a
|
||||
href={scratchUrl(scratch)}
|
||||
className={classNames(verticalMenuStyles.item, styles.item)}
|
||||
>
|
||||
<PlatformLink scratch={scratch} size={16} />
|
||||
<span className={styles.itemName}>
|
||||
{scratch.name}
|
||||
</span>
|
||||
{scratch.owner && (!api.isAnonUser(scratch.owner) ?
|
||||
userAvatarUrl(scratch.owner) && <Image
|
||||
src={userAvatarUrl(scratch.owner)}
|
||||
alt={scratch.owner.username}
|
||||
width={16}
|
||||
height={16}
|
||||
className={styles.scratchOwnerAvatar}
|
||||
/> :
|
||||
<AnonymousFrogAvatar
|
||||
user={scratch.owner}
|
||||
width={16}
|
||||
height={16}
|
||||
className={styles.scratchOwnerAvatar}
|
||||
/>)}
|
||||
</a>
|
||||
</li>
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SearchIcon className={styles.icon} />
|
||||
<input
|
||||
{...getInputProps(triggerProps)}
|
||||
className={classNames(styles.input, {
|
||||
[styles.isOpen]: isOpen,
|
||||
"rounded-md bg-transparent text-sm placeholder-current transition-colors hover:bg-gray-4 focus:bg-gray-5 focus:placeholder-gray-11": true,
|
||||
})}
|
||||
{items.length === 0 && <li>
|
||||
<div className={classNames(verticalMenuStyles.item, styles.noResults)}>
|
||||
No search results
|
||||
</div>
|
||||
</li>}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
type="text"
|
||||
placeholder="Search scratches"
|
||||
spellCheck={false}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onClick={() => setIsFocused(true)}
|
||||
/>
|
||||
{isLoading && isFocused && (
|
||||
<LoadingSpinner className={styles.loadingIcon} />
|
||||
)}
|
||||
{renderLayer(
|
||||
<ul
|
||||
{...getMenuProps(layerProps)}
|
||||
className={classNames(
|
||||
verticalMenuStyles.menu,
|
||||
styles.results,
|
||||
{
|
||||
[styles.isOpen]: isOpen,
|
||||
},
|
||||
)}
|
||||
style={{
|
||||
width: lastWidthRef.current,
|
||||
...layerProps.style,
|
||||
}}
|
||||
>
|
||||
{items.map((scratch, index) => {
|
||||
const props = getItemProps({ item: scratch, index });
|
||||
const oldOnClick = props.onClick;
|
||||
props.onClick = (evt) => {
|
||||
evt.preventDefault(); // Don't visit the link
|
||||
return oldOnClick(evt);
|
||||
};
|
||||
|
||||
return (
|
||||
<li key={scratchUrl(scratch)} {...props}>
|
||||
<a
|
||||
href={scratchUrl(scratch)}
|
||||
className={classNames(
|
||||
verticalMenuStyles.item,
|
||||
styles.item,
|
||||
)}
|
||||
>
|
||||
<PlatformLink scratch={scratch} size={16} />
|
||||
<span className={styles.itemName}>
|
||||
{scratch.name}
|
||||
</span>
|
||||
{scratch.owner &&
|
||||
(!api.isAnonUser(scratch.owner) ? (
|
||||
userAvatarUrl(scratch.owner) && (
|
||||
<Image
|
||||
src={userAvatarUrl(
|
||||
scratch.owner,
|
||||
)}
|
||||
alt={scratch.owner.username}
|
||||
width={16}
|
||||
height={16}
|
||||
className={
|
||||
styles.scratchOwnerAvatar
|
||||
}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<AnonymousFrogAvatar
|
||||
user={scratch.owner}
|
||||
width={16}
|
||||
height={16}
|
||||
className={
|
||||
styles.scratchOwnerAvatar
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{items.length === 0 && (
|
||||
<li>
|
||||
<div
|
||||
className={classNames(
|
||||
verticalMenuStyles.item,
|
||||
styles.noResults,
|
||||
)}
|
||||
>
|
||||
No search results
|
||||
</div>
|
||||
</li>
|
||||
)}
|
||||
</ul>,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Search({ className }: { className?: string }) {
|
||||
const [isMounted, setIsMounted] = useState(false)
|
||||
useEffect(() => setIsMounted(true), [])
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
useEffect(() => setIsMounted(true), []);
|
||||
|
||||
if (!isMounted) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
return <MountedSearch className={className} />
|
||||
return <MountedSearch className={className} />;
|
||||
}
|
||||
|
||||
@@ -1,46 +1,48 @@
|
||||
import { mutate } from "swr"
|
||||
import { mutate } from "swr";
|
||||
|
||||
import * as api from "@/lib/api"
|
||||
import * as api from "@/lib/api";
|
||||
|
||||
import GitHubLoginButton from "../GitHubLoginButton"
|
||||
import { MenuItem, ButtonItem, LinkItem } from "../VerticalMenu"
|
||||
import GitHubLoginButton from "../GitHubLoginButton";
|
||||
import { MenuItem, ButtonItem, LinkItem } from "../VerticalMenu";
|
||||
|
||||
import styles from "./UserMenuItems.module.scss"
|
||||
import styles from "./UserMenuItems.module.scss";
|
||||
|
||||
export default function UserMenuItems() {
|
||||
const user = api.useThisUser()
|
||||
const user = api.useThisUser();
|
||||
|
||||
if (api.isAnonUser(user)) {
|
||||
return <>
|
||||
<MenuItem>
|
||||
<div className={styles.status}>
|
||||
Sign in now to keep track of your scratches.
|
||||
</div>
|
||||
</MenuItem>
|
||||
<MenuItem>
|
||||
<GitHubLoginButton />
|
||||
</MenuItem>
|
||||
</>
|
||||
return (
|
||||
<>
|
||||
<MenuItem>
|
||||
<div className={styles.status}>
|
||||
Sign in now to keep track of your scratches.
|
||||
</div>
|
||||
</MenuItem>
|
||||
<MenuItem>
|
||||
<GitHubLoginButton />
|
||||
</MenuItem>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return <>
|
||||
<MenuItem>
|
||||
<div className={styles.status}>
|
||||
Signed in as <b>{user.username}</b>
|
||||
</div>
|
||||
</MenuItem>
|
||||
<LinkItem href={`/u/${user.username}`}>
|
||||
Your profile
|
||||
</LinkItem>
|
||||
<hr />
|
||||
{user.is_admin && <LinkItem href={"/admin"}>Admin</LinkItem>}
|
||||
<ButtonItem
|
||||
onTrigger={async () => {
|
||||
const user = await api.post("/user", {})
|
||||
await mutate("/user", user)
|
||||
}}
|
||||
>
|
||||
Sign out
|
||||
</ButtonItem>
|
||||
</>
|
||||
return (
|
||||
<>
|
||||
<MenuItem>
|
||||
<div className={styles.status}>
|
||||
Signed in as <b>{user.username}</b>
|
||||
</div>
|
||||
</MenuItem>
|
||||
<LinkItem href={`/u/${user.username}`}>Your profile</LinkItem>
|
||||
<hr />
|
||||
{user.is_admin && <LinkItem href={"/admin"}>Admin</LinkItem>}
|
||||
<ButtonItem
|
||||
onTrigger={async () => {
|
||||
const user = await api.post("/user", {});
|
||||
await mutate("/user", user);
|
||||
}}
|
||||
>
|
||||
Sign out
|
||||
</ButtonItem>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
import Nav from "./Nav"
|
||||
import Nav from "./Nav";
|
||||
|
||||
export default Nav
|
||||
export default Nav;
|
||||
|
||||
@@ -1,57 +1,68 @@
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import classNames from "classnames"
|
||||
import classNames from "classnames";
|
||||
|
||||
import styles from "./NumberInput.module.scss"
|
||||
import styles from "./NumberInput.module.scss";
|
||||
|
||||
export type Props = {
|
||||
value?: number
|
||||
onChange?: (value: number) => void
|
||||
stringValue?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
value?: number;
|
||||
onChange?: (value: number) => void;
|
||||
stringValue?: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export default function NumberInput({ value, onChange, stringValue, disabled }: Props) {
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const editableRef = useRef<HTMLSpanElement>()
|
||||
export default function NumberInput({
|
||||
value,
|
||||
onChange,
|
||||
stringValue,
|
||||
disabled,
|
||||
}: Props) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const editableRef = useRef<HTMLSpanElement>();
|
||||
|
||||
useEffect(() => {
|
||||
const el = editableRef.current
|
||||
const el = editableRef.current;
|
||||
|
||||
if (el) {
|
||||
const range = document.createRange()
|
||||
range.selectNodeContents(el)
|
||||
const sel = window.getSelection()
|
||||
sel.removeAllRanges()
|
||||
sel.addRange(range)
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(el);
|
||||
const sel = window.getSelection();
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
}
|
||||
}, [isEditing])
|
||||
}, [isEditing]);
|
||||
|
||||
return <span
|
||||
ref={editableRef}
|
||||
className={classNames(styles.numberInput, { [styles.disabled]: disabled })}
|
||||
tabIndex={0}
|
||||
contentEditable={isEditing && !disabled}
|
||||
suppressContentEditableWarning={true}
|
||||
onClick={() => setIsEditing(true)}
|
||||
onBlur={evt => {
|
||||
if (Number.isNaN(+evt.currentTarget.textContent)) {
|
||||
evt.currentTarget.textContent = `${value}` // this should never happen, as the user is not allowed to type non-digits
|
||||
}
|
||||
onChange(+evt.currentTarget.textContent)
|
||||
setIsEditing(false)
|
||||
}}
|
||||
onKeyPress={evt => {
|
||||
const isValidKey = evt.key === "." || !Number.isNaN(+evt.key)
|
||||
if (!isValidKey || disabled) {
|
||||
evt.preventDefault()
|
||||
}
|
||||
return (
|
||||
<span
|
||||
ref={editableRef}
|
||||
className={classNames(styles.numberInput, {
|
||||
[styles.disabled]: disabled,
|
||||
})}
|
||||
tabIndex={0}
|
||||
contentEditable={isEditing && !disabled}
|
||||
suppressContentEditableWarning={true}
|
||||
onClick={() => setIsEditing(true)}
|
||||
onBlur={(evt) => {
|
||||
if (Number.isNaN(+evt.currentTarget.textContent)) {
|
||||
evt.currentTarget.textContent = `${value}`; // this should never happen, as the user is not allowed to type non-digits
|
||||
}
|
||||
onChange(+evt.currentTarget.textContent);
|
||||
setIsEditing(false);
|
||||
}}
|
||||
onKeyPress={(evt) => {
|
||||
const isValidKey = evt.key === "." || !Number.isNaN(+evt.key);
|
||||
if (!isValidKey || disabled) {
|
||||
evt.preventDefault();
|
||||
}
|
||||
|
||||
if (evt.key === "Enter") {
|
||||
evt.currentTarget.blur() // submit
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isEditing ? editableRef.current.textContent : (stringValue ?? value)}
|
||||
</span>
|
||||
if (evt.key === "Enter") {
|
||||
evt.currentTarget.blur(); // submit
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isEditing
|
||||
? editableRef.current.textContent
|
||||
: (stringValue ?? value)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,26 @@
|
||||
import Link from "next/link"
|
||||
import Link from "next/link";
|
||||
|
||||
import type * as api from "@/lib/api"
|
||||
import { platformUrl } from "@/lib/api/urls"
|
||||
import type * as api from "@/lib/api";
|
||||
import { platformUrl } from "@/lib/api/urls";
|
||||
|
||||
import { platformIcon } from "./PlatformSelect/PlatformIcon"
|
||||
import { platformIcon } from "./PlatformSelect/PlatformIcon";
|
||||
|
||||
export type Props = {
|
||||
scratch: api.TerseScratch
|
||||
size: number
|
||||
className?: string
|
||||
}
|
||||
scratch: api.TerseScratch;
|
||||
size: number;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export default function PlatformLink(props: Props) {
|
||||
const Icon = platformIcon(props.scratch.platform)
|
||||
const Icon = platformIcon(props.scratch.platform);
|
||||
|
||||
return <Link href={platformUrl(props.scratch.platform)}>
|
||||
<Icon width={props.size} height={props.size} className={props.className} />
|
||||
</Link>
|
||||
return (
|
||||
<Link href={platformUrl(props.scratch.platform)}>
|
||||
<Icon
|
||||
width={props.size}
|
||||
height={props.size}
|
||||
className={props.className}
|
||||
/>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,69 +1,67 @@
|
||||
import Link from "next/link"
|
||||
import Link from "next/link";
|
||||
|
||||
import { platformUrl } from "@/lib/api/urls"
|
||||
import { platformUrl } from "@/lib/api/urls";
|
||||
|
||||
import LogoDreamcast from "./dreamcast.svg"
|
||||
import LogoGBA from "./gba.svg"
|
||||
import LogoGCWii from "./gc_wii.svg"
|
||||
import LogoIRIX from "./irix.svg"
|
||||
import LogoMacOSX from "./macosx.svg"
|
||||
import LogoMSDOS from "./msdos.svg"
|
||||
import LogoN3DS from "./n3ds.svg"
|
||||
import LogoN64 from "./n64.svg"
|
||||
import LogoNDS from "./nds.svg"
|
||||
import LogoPS1 from "./ps1.svg"
|
||||
import LogoPS2 from "./ps2.svg"
|
||||
import LogoPSP from "./psp.svg"
|
||||
import LogoSaturn from "./saturn.svg"
|
||||
import LogoSwitch from "./switch.svg"
|
||||
import UnknownIcon from "./unknown.svg"
|
||||
import LogoWin32 from "./win32.svg"
|
||||
import LogoDreamcast from "./dreamcast.svg";
|
||||
import LogoGBA from "./gba.svg";
|
||||
import LogoGCWii from "./gc_wii.svg";
|
||||
import LogoIRIX from "./irix.svg";
|
||||
import LogoMacOSX from "./macosx.svg";
|
||||
import LogoMSDOS from "./msdos.svg";
|
||||
import LogoN3DS from "./n3ds.svg";
|
||||
import LogoN64 from "./n64.svg";
|
||||
import LogoNDS from "./nds.svg";
|
||||
import LogoPS1 from "./ps1.svg";
|
||||
import LogoPS2 from "./ps2.svg";
|
||||
import LogoPSP from "./psp.svg";
|
||||
import LogoSaturn from "./saturn.svg";
|
||||
import LogoSwitch from "./switch.svg";
|
||||
import UnknownIcon from "./unknown.svg";
|
||||
import LogoWin32 from "./win32.svg";
|
||||
|
||||
/** In release-date order */
|
||||
const ICONS = {
|
||||
"msdos": LogoMSDOS,
|
||||
"irix": LogoIRIX,
|
||||
"win32": LogoWin32,
|
||||
"macosx": LogoMacOSX,
|
||||
"n64": LogoN64,
|
||||
"gba": LogoGBA,
|
||||
"gc_wii": LogoGCWii,
|
||||
"nds_arm9": LogoNDS,
|
||||
"ps1": LogoPS1,
|
||||
"ps2": LogoPS2,
|
||||
"psp": LogoPSP,
|
||||
"n3ds": LogoN3DS,
|
||||
"switch": LogoSwitch,
|
||||
"saturn": LogoSaturn,
|
||||
"dreamcast": LogoDreamcast,
|
||||
}
|
||||
msdos: LogoMSDOS,
|
||||
irix: LogoIRIX,
|
||||
win32: LogoWin32,
|
||||
macosx: LogoMacOSX,
|
||||
n64: LogoN64,
|
||||
gba: LogoGBA,
|
||||
gc_wii: LogoGCWii,
|
||||
nds_arm9: LogoNDS,
|
||||
ps1: LogoPS1,
|
||||
ps2: LogoPS2,
|
||||
psp: LogoPSP,
|
||||
n3ds: LogoN3DS,
|
||||
switch: LogoSwitch,
|
||||
saturn: LogoSaturn,
|
||||
dreamcast: LogoDreamcast,
|
||||
};
|
||||
|
||||
export const PLATFORMS = Object.keys(ICONS)
|
||||
export const PLATFORMS = Object.keys(ICONS);
|
||||
|
||||
export type Props = {
|
||||
platform: string
|
||||
className?: string
|
||||
clickable?: boolean
|
||||
size?: string | number
|
||||
}
|
||||
platform: string;
|
||||
className?: string;
|
||||
clickable?: boolean;
|
||||
size?: string | number;
|
||||
};
|
||||
|
||||
export function platformIcon(platform: string) {
|
||||
return ICONS[platform as keyof typeof ICONS] || UnknownIcon
|
||||
return ICONS[platform as keyof typeof ICONS] || UnknownIcon;
|
||||
}
|
||||
|
||||
export function PlatformIcon({ platform, className, clickable, size }: Props) {
|
||||
const Icon = platformIcon(platform)
|
||||
const url = platformUrl(platform)
|
||||
const Icon = platformIcon(platform);
|
||||
const url = platformUrl(platform);
|
||||
|
||||
if (clickable) {
|
||||
return (
|
||||
<Link href={url}>
|
||||
<Icon width={size} height={size} className={className} />
|
||||
</Link>
|
||||
)
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Icon width={size} height={size} className={className} />
|
||||
)
|
||||
return <Icon width={size} height={size} className={className} />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
import Link from "next/link"
|
||||
import Link from "next/link";
|
||||
|
||||
import useSWRImmutable from "swr/immutable"
|
||||
import useSWRImmutable from "swr/immutable";
|
||||
|
||||
import * as api from "@/lib/api"
|
||||
import * as api from "@/lib/api";
|
||||
|
||||
export type Props = {
|
||||
platform: string
|
||||
}
|
||||
platform: string;
|
||||
};
|
||||
|
||||
export default function PlatformName({ platform }: Props) {
|
||||
const { data } = useSWRImmutable<api.PlatformBase>(`/platform/${platform}`, api.get)
|
||||
const { data } = useSWRImmutable<api.PlatformBase>(
|
||||
`/platform/${platform}`,
|
||||
api.get,
|
||||
);
|
||||
|
||||
return <>
|
||||
<Link href={`/platform/${platform}`}>
|
||||
{data?.name ?? platform}
|
||||
</Link>
|
||||
</>
|
||||
return (
|
||||
<>
|
||||
<Link href={`/platform/${platform}`}>{data?.name ?? platform}</Link>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,35 +1,49 @@
|
||||
import classNames from "classnames"
|
||||
import classNames from "classnames";
|
||||
|
||||
import { PlatformIcon } from "./PlatformIcon"
|
||||
import styles from "./PlatformSelect.module.scss"
|
||||
import { PlatformIcon } from "./PlatformIcon";
|
||||
import styles from "./PlatformSelect.module.scss";
|
||||
|
||||
export type Props = {
|
||||
platforms: {
|
||||
[key: string]: {
|
||||
name: string
|
||||
description: string
|
||||
}
|
||||
}
|
||||
value: string
|
||||
className?: string
|
||||
onChange: (value: string) => void
|
||||
}
|
||||
name: string;
|
||||
description: string;
|
||||
};
|
||||
};
|
||||
value: string;
|
||||
className?: string;
|
||||
onChange: (value: string) => void;
|
||||
};
|
||||
|
||||
export default function PlatformSelect({ platforms, value, onChange, className }: Props) {
|
||||
if (!value)
|
||||
onChange("n64")
|
||||
export default function PlatformSelect({
|
||||
platforms,
|
||||
value,
|
||||
onChange,
|
||||
className,
|
||||
}: Props) {
|
||||
if (!value) onChange("n64");
|
||||
|
||||
return <ul className={classNames(styles.container, className)}>
|
||||
{Object.entries(platforms).map(([key, platform]) => <li
|
||||
key={key}
|
||||
className={classNames(styles.platform, { [styles.selected]: value === key })}
|
||||
onClick={() => onChange(key)}
|
||||
>
|
||||
<PlatformIcon clickable={false} platform={key} />
|
||||
<div className={styles.labelContainer}>
|
||||
<div className={styles.consoleName}>{platform.name}</div>
|
||||
<div className={styles.platformName}>{platform.description}</div>
|
||||
</div>
|
||||
</li>)}
|
||||
</ul>
|
||||
return (
|
||||
<ul className={classNames(styles.container, className)}>
|
||||
{Object.entries(platforms).map(([key, platform]) => (
|
||||
<li
|
||||
key={key}
|
||||
className={classNames(styles.platform, {
|
||||
[styles.selected]: value === key,
|
||||
})}
|
||||
onClick={() => onChange(key)}
|
||||
>
|
||||
<PlatformIcon clickable={false} platform={key} />
|
||||
<div className={styles.labelContainer}>
|
||||
<div className={styles.consoleName}>
|
||||
{platform.name}
|
||||
</div>
|
||||
<div className={styles.platformName}>
|
||||
{platform.description}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,27 @@
|
||||
import classNames from "classnames"
|
||||
import classNames from "classnames";
|
||||
|
||||
import { PlatformIcon, PLATFORMS } from "./PlatformIcon"
|
||||
import styles from "./ScrollingPlatformIcons.module.scss"
|
||||
import { PlatformIcon, PLATFORMS } from "./PlatformIcon";
|
||||
import styles from "./ScrollingPlatformIcons.module.scss";
|
||||
|
||||
function SingleSet() {
|
||||
return <div className={classNames("flex gap-2", styles.scrolling)}>
|
||||
{PLATFORMS.map(platform => <PlatformIcon key={platform} platform={platform} className="ml-16 size-24 md:size-32" />)}
|
||||
</div>
|
||||
return (
|
||||
<div className={classNames("flex gap-2", styles.scrolling)}>
|
||||
{PLATFORMS.map((platform) => (
|
||||
<PlatformIcon
|
||||
key={platform}
|
||||
platform={platform}
|
||||
className="ml-16 size-24 md:size-32"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ScrollingPlatformIcons() {
|
||||
return <div className="pointer-events-none flex" aria-hidden>
|
||||
<SingleSet />
|
||||
<SingleSet />
|
||||
</div>
|
||||
return (
|
||||
<div className="pointer-events-none flex" aria-hidden>
|
||||
<SingleSet />
|
||||
<SingleSet />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import PlatformSelect, { type Props as PlatformSelectProps } from "./PlatformSelect"
|
||||
import PlatformSelect, {
|
||||
type Props as PlatformSelectProps,
|
||||
} from "./PlatformSelect";
|
||||
|
||||
export type Props = PlatformSelectProps
|
||||
export default PlatformSelect
|
||||
export type Props = PlatformSelectProps;
|
||||
export default PlatformSelect;
|
||||
|
||||
@@ -1,73 +1,109 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import type { ReactNode, JSX } from "react"
|
||||
import type { ReactNode, JSX } from "react";
|
||||
|
||||
import Link from "next/link"
|
||||
import Link from "next/link";
|
||||
|
||||
import classNames from "classnames"
|
||||
import classNames from "classnames";
|
||||
|
||||
import AsyncButton from "@/components/AsyncButton"
|
||||
import Button from "@/components/Button"
|
||||
import LoadingSpinner from "@/components/loading.svg"
|
||||
import { PlatformIcon } from "@/components/PlatformSelect/PlatformIcon"
|
||||
import { type Preset, usePaginated } from "@/lib/api"
|
||||
import { presetUrl } from "@/lib/api/urls"
|
||||
import getTranslation from "@/lib/i18n/translate"
|
||||
import AsyncButton from "@/components/AsyncButton";
|
||||
import Button from "@/components/Button";
|
||||
import LoadingSpinner from "@/components/loading.svg";
|
||||
import { PlatformIcon } from "@/components/PlatformSelect/PlatformIcon";
|
||||
import { type Preset, usePaginated } from "@/lib/api";
|
||||
import { presetUrl } from "@/lib/api/urls";
|
||||
import getTranslation from "@/lib/i18n/translate";
|
||||
|
||||
export interface Props {
|
||||
url?: string
|
||||
className?: string
|
||||
item?: ({ preset }: { preset: Preset }) => JSX.Element
|
||||
emptyButtonLabel?: ReactNode
|
||||
url?: string;
|
||||
className?: string;
|
||||
item?: ({ preset }: { preset: Preset }) => JSX.Element;
|
||||
emptyButtonLabel?: ReactNode;
|
||||
}
|
||||
|
||||
export function PresetList({ url, className, item, emptyButtonLabel }: Props): JSX.Element {
|
||||
const { results, isLoading, hasNext, loadNext } = usePaginated<Preset>(url || "/preset")
|
||||
export function PresetList({
|
||||
url,
|
||||
className,
|
||||
item,
|
||||
emptyButtonLabel,
|
||||
}: Props): JSX.Element {
|
||||
const { results, isLoading, hasNext, loadNext } = usePaginated<Preset>(
|
||||
url || "/preset",
|
||||
);
|
||||
if (results.length === 0 && isLoading) {
|
||||
return <div className={classNames("flex items-center justify-center gap-[0.5em] p-[1em] opacity-50", className)}>
|
||||
<LoadingSpinner width="1.5em" height="1.5em" />
|
||||
Just a moment...
|
||||
</div>
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
"flex items-center justify-center gap-[0.5em] p-[1em] opacity-50",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<LoadingSpinner width="1.5em" height="1.5em" />
|
||||
Just a moment...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const Item = item ?? PresetItem
|
||||
const Item = item ?? PresetItem;
|
||||
|
||||
return (
|
||||
<ul className={classNames("flex flex-col justify-center gap-[0.5em] overflow-hidden rounded-md border-gray-6 text-sm", className)}>
|
||||
{results.map(preset => (
|
||||
<ul
|
||||
className={classNames(
|
||||
"flex flex-col justify-center gap-[0.5em] overflow-hidden rounded-md border-gray-6 text-sm",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{results.map((preset) => (
|
||||
<Item hideIcon key={preset.id} preset={preset} />
|
||||
))}
|
||||
{results.length === 0 && emptyButtonLabel && <li className={"col-[span_var(--num-columns,_1)] mt-[0.5em] flex items-center justify-center opacity-70"}>
|
||||
<Link href="/new">
|
||||
|
||||
<Button>
|
||||
{emptyButtonLabel}
|
||||
</Button>
|
||||
|
||||
</Link>
|
||||
</li>}
|
||||
{hasNext && <li className={"col-[span_var(--num-columns,_1)] mt-[0.5em] flex items-center justify-center opacity-70"}>
|
||||
<AsyncButton onClick={loadNext}>
|
||||
Show more
|
||||
</AsyncButton>
|
||||
</li>}
|
||||
{results.length === 0 && emptyButtonLabel && (
|
||||
<li
|
||||
className={
|
||||
"col-[span_var(--num-columns,_1)] mt-[0.5em] flex items-center justify-center opacity-70"
|
||||
}
|
||||
>
|
||||
<Link href="/new">
|
||||
<Button>{emptyButtonLabel}</Button>
|
||||
</Link>
|
||||
</li>
|
||||
)}
|
||||
{hasNext && (
|
||||
<li
|
||||
className={
|
||||
"col-[span_var(--num-columns,_1)] mt-[0.5em] flex items-center justify-center opacity-70"
|
||||
}
|
||||
>
|
||||
<AsyncButton onClick={loadNext}>Show more</AsyncButton>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function PresetItem({ preset, hideIcon }: { preset: Preset, hideIcon?: boolean }): JSX.Element {
|
||||
const compilersTranslation = getTranslation("compilers")
|
||||
const compilerName = compilersTranslation.t(preset.compiler)
|
||||
export function PresetItem({
|
||||
preset,
|
||||
hideIcon,
|
||||
}: { preset: Preset; hideIcon?: boolean }): JSX.Element {
|
||||
const compilersTranslation = getTranslation("compilers");
|
||||
const compilerName = compilersTranslation.t(preset.compiler);
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-gray-6 p-[1em] text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
{hideIcon && <PlatformIcon platform={preset.platform} className="w-[1.2em]"/>}
|
||||
<a className="font-semibold hover:text-[var(--link)]" href={presetUrl(preset)}>
|
||||
{hideIcon && (
|
||||
<PlatformIcon
|
||||
platform={preset.platform}
|
||||
className="w-[1.2em]"
|
||||
/>
|
||||
)}
|
||||
<a
|
||||
className="font-semibold hover:text-[var(--link)]"
|
||||
href={presetUrl(preset)}
|
||||
>
|
||||
{preset.name}
|
||||
</a>
|
||||
</div>
|
||||
<p className="text-gray-11">{compilerName}</p>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,76 +1,97 @@
|
||||
import { AlertIcon, CheckIcon } from "@primer/octicons-react"
|
||||
import classNames from "classnames"
|
||||
import { AlertIcon, CheckIcon } from "@primer/octicons-react";
|
||||
import classNames from "classnames";
|
||||
|
||||
import styles from "./ScoreBadge.module.scss"
|
||||
import styles from "./ScoreBadge.module.scss";
|
||||
|
||||
export function calculateScorePercent(score: number, maxScore: number): number {
|
||||
if (score > maxScore) {
|
||||
return 0
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (maxScore === 0) {
|
||||
return 0
|
||||
return 0;
|
||||
}
|
||||
|
||||
return ((1 - (score / maxScore)) * 100)
|
||||
return (1 - score / maxScore) * 100;
|
||||
}
|
||||
|
||||
export function percentToString(percent: number): string {
|
||||
// If the percent is an integer, don't show the decimal
|
||||
if (Math.floor(percent * 100) / 100 === Math.floor(percent)) {
|
||||
return `${Math.floor(percent)}%`
|
||||
return `${Math.floor(percent)}%`;
|
||||
}
|
||||
// If percent is between 99.99 and 100 exclusive, always round down
|
||||
if (99.99 < percent && percent < 100) {
|
||||
return "99.99%"
|
||||
return "99.99%";
|
||||
}
|
||||
return `${percent.toFixed(2)}%`
|
||||
return `${percent.toFixed(2)}%`;
|
||||
}
|
||||
|
||||
export function getScoreText(score: number, maxScore: number, matchOverride: boolean): string {
|
||||
export function getScoreText(
|
||||
score: number,
|
||||
maxScore: number,
|
||||
matchOverride: boolean,
|
||||
): string {
|
||||
if (score === -1) {
|
||||
return "No score available"
|
||||
return "No score available";
|
||||
} else if (score === 0) {
|
||||
return "0 (100%) 🎊"
|
||||
return "0 (100%) 🎊";
|
||||
} else if (matchOverride) {
|
||||
return `${score} (100%) 🎊 (override)`
|
||||
return `${score} (100%) 🎊 (override)`;
|
||||
} else {
|
||||
const percent = calculateScorePercent(score, maxScore)
|
||||
const percent = calculateScorePercent(score, maxScore);
|
||||
|
||||
return `${score} (${percentToString(percent)})`
|
||||
return `${score} (${percentToString(percent)})`;
|
||||
}
|
||||
}
|
||||
|
||||
export function getScoreAsFraction(score: number, maxScore: number): string {
|
||||
if (score === -1) {
|
||||
return `???/${maxScore}`
|
||||
return `???/${maxScore}`;
|
||||
} else {
|
||||
return `${maxScore - score}/${maxScore}`
|
||||
return `${maxScore - score}/${maxScore}`;
|
||||
}
|
||||
}
|
||||
|
||||
export type Props = {
|
||||
score: number
|
||||
maxScore: number
|
||||
matchOverride: boolean
|
||||
compiledSuccessfully: boolean
|
||||
}
|
||||
score: number;
|
||||
maxScore: number;
|
||||
matchOverride: boolean;
|
||||
compiledSuccessfully: boolean;
|
||||
};
|
||||
|
||||
export default function ScoreBadge({ score, maxScore, matchOverride, compiledSuccessfully }: Props) {
|
||||
export default function ScoreBadge({
|
||||
score,
|
||||
maxScore,
|
||||
matchOverride,
|
||||
compiledSuccessfully,
|
||||
}: Props) {
|
||||
if (!compiledSuccessfully || score === -1) {
|
||||
return <div className={classNames(styles.badge, { [styles.error]: true })} title="Does not compile">
|
||||
<AlertIcon className={styles.icon} />
|
||||
</div>
|
||||
return (
|
||||
<div
|
||||
className={classNames(styles.badge, { [styles.error]: true })}
|
||||
title="Does not compile"
|
||||
>
|
||||
<AlertIcon className={styles.icon} />
|
||||
</div>
|
||||
);
|
||||
} else if (score === 0) {
|
||||
return <div className={classNames(styles.badge, { [styles.match]: true })} title="Match">
|
||||
<CheckIcon className={styles.icon} />
|
||||
</div>
|
||||
return (
|
||||
<div
|
||||
className={classNames(styles.badge, { [styles.match]: true })}
|
||||
title="Match"
|
||||
>
|
||||
<CheckIcon className={styles.icon} />
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
const text = getScoreText(score, maxScore, matchOverride)
|
||||
const title = getScoreAsFraction(score, maxScore)
|
||||
const text = getScoreText(score, maxScore, matchOverride);
|
||||
const title = getScoreAsFraction(score, maxScore);
|
||||
|
||||
return <div className={styles.badge} aria-label="Score" title={title}>
|
||||
{text}
|
||||
</div>
|
||||
return (
|
||||
<div className={styles.badge} aria-label="Score" title={title}>
|
||||
{text}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,32 +1,41 @@
|
||||
import { useEffect, useReducer, useRef, useState } from "react"
|
||||
import { useEffect, useReducer, useRef, useState } from "react";
|
||||
|
||||
import type { EditorView } from "@codemirror/view"
|
||||
import { vim } from "@replit/codemirror-vim"
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
import { vim } from "@replit/codemirror-vim";
|
||||
|
||||
import * as api from "@/lib/api"
|
||||
import basicSetup from "@/lib/codemirror/basic-setup"
|
||||
import { cpp } from "@/lib/codemirror/cpp"
|
||||
import useCompareExtension from "@/lib/codemirror/useCompareExtension"
|
||||
import { useSize } from "@/lib/hooks"
|
||||
import { useAutoRecompileSetting, useAutoRecompileDelaySetting, useLanguageServerEnabled, useVimModeEnabled, useMatchProgressBarEnabled } from "@/lib/settings"
|
||||
import * as api from "@/lib/api";
|
||||
import basicSetup from "@/lib/codemirror/basic-setup";
|
||||
import { cpp } from "@/lib/codemirror/cpp";
|
||||
import useCompareExtension from "@/lib/codemirror/useCompareExtension";
|
||||
import { useSize } from "@/lib/hooks";
|
||||
import {
|
||||
useAutoRecompileSetting,
|
||||
useAutoRecompileDelaySetting,
|
||||
useLanguageServerEnabled,
|
||||
useVimModeEnabled,
|
||||
useMatchProgressBarEnabled,
|
||||
} from "@/lib/settings";
|
||||
|
||||
import CompilerOpts from "../compiler/CompilerOpts"
|
||||
import CustomLayout, { activateTabInLayout, type Layout } from "../CustomLayout"
|
||||
import CompilationPanel from "../Diff/CompilationPanel"
|
||||
import CodeMirror from "../Editor/CodeMirror"
|
||||
import ErrorBoundary from "../ErrorBoundary"
|
||||
import ScoreBadge, { calculateScorePercent } from "../ScoreBadge"
|
||||
import { ScrollContext } from "../ScrollContext"
|
||||
import { Tab, TabCloseButton } from "../Tabs"
|
||||
import CompilerOpts from "../compiler/CompilerOpts";
|
||||
import CustomLayout, {
|
||||
activateTabInLayout,
|
||||
type Layout,
|
||||
} from "../CustomLayout";
|
||||
import CompilationPanel from "../Diff/CompilationPanel";
|
||||
import CodeMirror from "../Editor/CodeMirror";
|
||||
import ErrorBoundary from "../ErrorBoundary";
|
||||
import ScoreBadge, { calculateScorePercent } from "../ScoreBadge";
|
||||
import { ScrollContext } from "../ScrollContext";
|
||||
import { Tab, TabCloseButton } from "../Tabs";
|
||||
|
||||
import useLanguageServer from "./hooks/useLanguageServer"
|
||||
import AboutPanel from "./panels/AboutPanel"
|
||||
import DecompilationPanel from "./panels/DecompilePanel"
|
||||
import FamilyPanel from "./panels/FamilyPanel"
|
||||
import styles from "./Scratch.module.scss"
|
||||
import ScratchMatchBanner from "./ScratchMatchBanner"
|
||||
import ScratchProgressBar from "./ScratchProgressBar"
|
||||
import ScratchToolbar from "./ScratchToolbar"
|
||||
import useLanguageServer from "./hooks/useLanguageServer";
|
||||
import AboutPanel from "./panels/AboutPanel";
|
||||
import DecompilationPanel from "./panels/DecompilePanel";
|
||||
import FamilyPanel from "./panels/FamilyPanel";
|
||||
import styles from "./Scratch.module.scss";
|
||||
import ScratchMatchBanner from "./ScratchMatchBanner";
|
||||
import ScratchProgressBar from "./ScratchProgressBar";
|
||||
import ScratchToolbar from "./ScratchToolbar";
|
||||
|
||||
enum TabId {
|
||||
ABOUT = "scratch_about",
|
||||
@@ -62,10 +71,7 @@ const DEFAULT_LAYOUTS: Record<"desktop_2col" | "mobile_2row", Layout> = {
|
||||
kind: "pane",
|
||||
size: 50,
|
||||
activeTab: TabId.DIFF,
|
||||
tabs: [
|
||||
TabId.DIFF,
|
||||
TabId.DECOMPILATION,
|
||||
],
|
||||
tabs: [TabId.DIFF, TabId.DECOMPILATION],
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -91,35 +97,31 @@ const DEFAULT_LAYOUTS: Record<"desktop_2col" | "mobile_2row", Layout> = {
|
||||
kind: "pane",
|
||||
size: 50,
|
||||
activeTab: TabId.SOURCE_CODE,
|
||||
tabs: [
|
||||
TabId.SOURCE_CODE,
|
||||
TabId.CONTEXT,
|
||||
TabId.OPTIONS,
|
||||
],
|
||||
tabs: [TabId.SOURCE_CODE, TabId.CONTEXT, TabId.OPTIONS],
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
const CODEMIRROR_EXTENSIONS = [
|
||||
basicSetup,
|
||||
cpp(),
|
||||
]
|
||||
function getDefaultLayout(width: number, _height: number): keyof typeof DEFAULT_LAYOUTS {
|
||||
const CODEMIRROR_EXTENSIONS = [basicSetup, cpp()];
|
||||
function getDefaultLayout(
|
||||
width: number,
|
||||
_height: number,
|
||||
): keyof typeof DEFAULT_LAYOUTS {
|
||||
if (width > 700) {
|
||||
return "desktop_2col"
|
||||
return "desktop_2col";
|
||||
}
|
||||
|
||||
return "mobile_2row"
|
||||
return "mobile_2row";
|
||||
}
|
||||
|
||||
export type Props = {
|
||||
scratch: Readonly<api.Scratch>
|
||||
onChange: (scratch: Partial<api.Scratch>) => void
|
||||
parentScratch?: api.Scratch
|
||||
initialCompilation?: Readonly<api.Compilation>
|
||||
offline: boolean
|
||||
}
|
||||
scratch: Readonly<api.Scratch>;
|
||||
onChange: (scratch: Partial<api.Scratch>) => void;
|
||||
parentScratch?: api.Scratch;
|
||||
initialCompilation?: Readonly<api.Compilation>;
|
||||
offline: boolean;
|
||||
};
|
||||
|
||||
export default function Scratch({
|
||||
scratch,
|
||||
@@ -128,225 +130,322 @@ export default function Scratch({
|
||||
initialCompilation,
|
||||
offline,
|
||||
}: Props) {
|
||||
const container = useSize<HTMLDivElement>()
|
||||
const [layout, setLayout] = useState<Layout>(undefined)
|
||||
const [layoutName, setLayoutName] = useState<keyof typeof DEFAULT_LAYOUTS>(undefined)
|
||||
const container = useSize<HTMLDivElement>();
|
||||
const [layout, setLayout] = useState<Layout>(undefined);
|
||||
const [layoutName, setLayoutName] =
|
||||
useState<keyof typeof DEFAULT_LAYOUTS>(undefined);
|
||||
|
||||
const [autoRecompileSetting] = useAutoRecompileSetting()
|
||||
const [autoRecompileDelaySetting] = useAutoRecompileDelaySetting()
|
||||
const [languageServerEnabledSetting] = useLanguageServerEnabled()
|
||||
const [matchProgressBarEnabledSetting] = useMatchProgressBarEnabled()
|
||||
const { compilation, isCompiling, isCompilationOld, compile } = api.useCompilation(scratch, autoRecompileSetting, autoRecompileDelaySetting, initialCompilation)
|
||||
const userIsYou = api.useUserIsYou()
|
||||
const [selectedSourceLine, setSelectedSourceLine] = useState<number | null>()
|
||||
const sourceEditor = useRef<EditorView>()
|
||||
const contextEditor = useRef<EditorView>()
|
||||
const [valueVersion, incrementValueVersion] = useReducer(x => x + 1, 0)
|
||||
const [autoRecompileSetting] = useAutoRecompileSetting();
|
||||
const [autoRecompileDelaySetting] = useAutoRecompileDelaySetting();
|
||||
const [languageServerEnabledSetting] = useLanguageServerEnabled();
|
||||
const [matchProgressBarEnabledSetting] = useMatchProgressBarEnabled();
|
||||
const { compilation, isCompiling, isCompilationOld, compile } =
|
||||
api.useCompilation(
|
||||
scratch,
|
||||
autoRecompileSetting,
|
||||
autoRecompileDelaySetting,
|
||||
initialCompilation,
|
||||
);
|
||||
const userIsYou = api.useUserIsYou();
|
||||
const [selectedSourceLine, setSelectedSourceLine] = useState<
|
||||
number | null
|
||||
>();
|
||||
const sourceEditor = useRef<EditorView>();
|
||||
const contextEditor = useRef<EditorView>();
|
||||
const [valueVersion, incrementValueVersion] = useReducer((x) => x + 1, 0);
|
||||
|
||||
const [isModified, setIsModified] = useState(false)
|
||||
const [isModified, setIsModified] = useState(false);
|
||||
const setScratch = (scratch: Partial<api.Scratch>) => {
|
||||
onChange(scratch)
|
||||
setIsModified(true)
|
||||
}
|
||||
const [perSaveObj, setPerSaveObj] = useState({})
|
||||
onChange(scratch);
|
||||
setIsModified(true);
|
||||
};
|
||||
const [perSaveObj, setPerSaveObj] = useState({});
|
||||
const saveCallback = () => {
|
||||
setPerSaveObj({})
|
||||
}
|
||||
setPerSaveObj({});
|
||||
};
|
||||
|
||||
const shouldCompare = !isModified
|
||||
const sourceCompareExtension = useCompareExtension(sourceEditor, shouldCompare ? parentScratch?.source_code : undefined)
|
||||
const contextCompareExtension = useCompareExtension(contextEditor, shouldCompare ? parentScratch?.context : undefined)
|
||||
const shouldCompare = !isModified;
|
||||
const sourceCompareExtension = useCompareExtension(
|
||||
sourceEditor,
|
||||
shouldCompare ? parentScratch?.source_code : undefined,
|
||||
);
|
||||
const contextCompareExtension = useCompareExtension(
|
||||
contextEditor,
|
||||
shouldCompare ? parentScratch?.context : undefined,
|
||||
);
|
||||
|
||||
const [saveSource, saveContext] = useLanguageServer(languageServerEnabledSetting, scratch, sourceEditor, contextEditor)
|
||||
const [saveSource, saveContext] = useLanguageServer(
|
||||
languageServerEnabledSetting,
|
||||
scratch,
|
||||
sourceEditor,
|
||||
contextEditor,
|
||||
);
|
||||
|
||||
const lastGoodScore = useRef<number>(scratch.score)
|
||||
const lastGoodMaxScore = useRef<number>(scratch.max_score)
|
||||
const lastGoodScore = useRef<number>(scratch.score);
|
||||
const lastGoodMaxScore = useRef<number>(scratch.max_score);
|
||||
if (compilation?.success) {
|
||||
lastGoodScore.current = compilation?.diff_output?.current_score
|
||||
lastGoodMaxScore.current = compilation?.diff_output?.max_score
|
||||
lastGoodScore.current = compilation?.diff_output?.current_score;
|
||||
lastGoodMaxScore.current = compilation?.diff_output?.max_score;
|
||||
}
|
||||
|
||||
// TODO: CustomLayout should handle adding/removing tabs
|
||||
const [decompilationTabEnabled, setDecompilationTabEnabled] = useState(false)
|
||||
const [decompilationTabEnabled, setDecompilationTabEnabled] =
|
||||
useState(false);
|
||||
useEffect(() => {
|
||||
if (decompilationTabEnabled) {
|
||||
setLayout(layout => {
|
||||
const clone = { ...layout }
|
||||
activateTabInLayout(clone, TabId.DECOMPILATION)
|
||||
return clone
|
||||
})
|
||||
setLayout((layout) => {
|
||||
const clone = { ...layout };
|
||||
activateTabInLayout(clone, TabId.DECOMPILATION);
|
||||
return clone;
|
||||
});
|
||||
}
|
||||
}, [decompilationTabEnabled])
|
||||
}, [decompilationTabEnabled]);
|
||||
|
||||
// If the version of the scratch changes, refresh code editors
|
||||
useEffect(() => {
|
||||
incrementValueVersion()
|
||||
}, [scratch.slug, scratch.last_updated])
|
||||
incrementValueVersion();
|
||||
}, [scratch.slug, scratch.last_updated]);
|
||||
|
||||
const [useVim] = useVimModeEnabled()
|
||||
const cmExtensionsSource = [...CODEMIRROR_EXTENSIONS, sourceCompareExtension]
|
||||
const cmExtensionsContext = [...CODEMIRROR_EXTENSIONS, contextCompareExtension]
|
||||
const [useVim] = useVimModeEnabled();
|
||||
const cmExtensionsSource = [
|
||||
...CODEMIRROR_EXTENSIONS,
|
||||
sourceCompareExtension,
|
||||
];
|
||||
const cmExtensionsContext = [
|
||||
...CODEMIRROR_EXTENSIONS,
|
||||
contextCompareExtension,
|
||||
];
|
||||
if (useVim) {
|
||||
cmExtensionsSource.push(vim())
|
||||
cmExtensionsContext.push(vim())
|
||||
cmExtensionsSource.push(vim());
|
||||
cmExtensionsContext.push(vim());
|
||||
}
|
||||
|
||||
const renderTab = (id: string) => {
|
||||
switch (id as TabId) {
|
||||
case TabId.ABOUT:
|
||||
return <Tab key={id} tabKey={id} label="About" className={styles.about}>
|
||||
<AboutPanel
|
||||
scratch={scratch}
|
||||
setScratch={userIsYou(scratch.owner) ? setScratch : null}
|
||||
/>
|
||||
</Tab>
|
||||
case TabId.SOURCE_CODE:
|
||||
return <Tab
|
||||
key={id}
|
||||
tabKey={id}
|
||||
label="Source code"
|
||||
onSelect={() => {
|
||||
sourceEditor.current?.focus?.()
|
||||
saveContext()
|
||||
}}
|
||||
>
|
||||
<CodeMirror
|
||||
viewRef={sourceEditor}
|
||||
className={styles.editor}
|
||||
value={scratch.source_code}
|
||||
valueVersion={valueVersion}
|
||||
onChange={value => {
|
||||
setScratch({ source_code: value })
|
||||
}}
|
||||
onSelectedLineChange={setSelectedSourceLine}
|
||||
extensions={cmExtensionsSource}
|
||||
/>
|
||||
</Tab>
|
||||
case TabId.CONTEXT:
|
||||
return <Tab
|
||||
key={id}
|
||||
tabKey={id}
|
||||
label="Context"
|
||||
className={styles.context}
|
||||
onSelect={() => {
|
||||
contextEditor.current?.focus?.()
|
||||
saveSource()
|
||||
}}
|
||||
>
|
||||
<CodeMirror
|
||||
viewRef={contextEditor}
|
||||
className={styles.editor}
|
||||
value={scratch.context}
|
||||
valueVersion={valueVersion}
|
||||
onChange={value => {
|
||||
setScratch({ context: value })
|
||||
}}
|
||||
extensions={cmExtensionsContext}
|
||||
/>
|
||||
</Tab>
|
||||
case TabId.OPTIONS:
|
||||
return <Tab key={id} tabKey={id} label="Options" className={styles.compilerOptsTab}>
|
||||
<div className={styles.compilerOptsContainer}>
|
||||
<CompilerOpts
|
||||
platform={scratch.platform}
|
||||
value={scratch}
|
||||
onChange={setScratch}
|
||||
|
||||
diffLabel={scratch.diff_label}
|
||||
onDiffLabelChange={d => setScratch({ diff_label: d })}
|
||||
|
||||
matchOverride={scratch.match_override}
|
||||
onMatchOverrideChange={m => setScratch({ match_override: m })}
|
||||
/>
|
||||
</div>
|
||||
</Tab>
|
||||
case TabId.DIFF:
|
||||
return <Tab
|
||||
key={id}
|
||||
tabKey={id}
|
||||
label={<>
|
||||
Compilation
|
||||
{compilation && <ScoreBadge
|
||||
score={compilation?.diff_output?.current_score ?? -1}
|
||||
maxScore={compilation?.diff_output?.max_score ?? -1}
|
||||
matchOverride={scratch.match_override}
|
||||
compiledSuccessfully={compilation?.success ?? false} />}
|
||||
</>}
|
||||
className={styles.diffTab}
|
||||
>
|
||||
{compilation && <CompilationPanel
|
||||
scratch={scratch}
|
||||
compilation={compilation}
|
||||
isCompiling={isCompiling}
|
||||
isCompilationOld={isCompilationOld}
|
||||
selectedSourceLine={selectedSourceLine}
|
||||
perSaveObj={perSaveObj}
|
||||
/>}
|
||||
</Tab>
|
||||
case TabId.DECOMPILATION:
|
||||
return decompilationTabEnabled && <Tab
|
||||
key={id}
|
||||
tabKey={id}
|
||||
label={<>
|
||||
Decompilation
|
||||
<TabCloseButton onClick={() => setDecompilationTabEnabled(false)} />
|
||||
</>}
|
||||
>
|
||||
{() => <DecompilationPanel scratch={scratch} />}
|
||||
</Tab>
|
||||
case TabId.FAMILY:
|
||||
return <Tab key={id} tabKey={id} label="Family">
|
||||
{() => <FamilyPanel scratch={scratch} />}
|
||||
</Tab>
|
||||
default:
|
||||
return <Tab key={id} tabKey={id} label={id} disabled />
|
||||
case TabId.ABOUT:
|
||||
return (
|
||||
<Tab
|
||||
key={id}
|
||||
tabKey={id}
|
||||
label="About"
|
||||
className={styles.about}
|
||||
>
|
||||
<AboutPanel
|
||||
scratch={scratch}
|
||||
setScratch={
|
||||
userIsYou(scratch.owner) ? setScratch : null
|
||||
}
|
||||
/>
|
||||
</Tab>
|
||||
);
|
||||
case TabId.SOURCE_CODE:
|
||||
return (
|
||||
<Tab
|
||||
key={id}
|
||||
tabKey={id}
|
||||
label="Source code"
|
||||
onSelect={() => {
|
||||
sourceEditor.current?.focus?.();
|
||||
saveContext();
|
||||
}}
|
||||
>
|
||||
<CodeMirror
|
||||
viewRef={sourceEditor}
|
||||
className={styles.editor}
|
||||
value={scratch.source_code}
|
||||
valueVersion={valueVersion}
|
||||
onChange={(value) => {
|
||||
setScratch({ source_code: value });
|
||||
}}
|
||||
onSelectedLineChange={setSelectedSourceLine}
|
||||
extensions={cmExtensionsSource}
|
||||
/>
|
||||
</Tab>
|
||||
);
|
||||
case TabId.CONTEXT:
|
||||
return (
|
||||
<Tab
|
||||
key={id}
|
||||
tabKey={id}
|
||||
label="Context"
|
||||
className={styles.context}
|
||||
onSelect={() => {
|
||||
contextEditor.current?.focus?.();
|
||||
saveSource();
|
||||
}}
|
||||
>
|
||||
<CodeMirror
|
||||
viewRef={contextEditor}
|
||||
className={styles.editor}
|
||||
value={scratch.context}
|
||||
valueVersion={valueVersion}
|
||||
onChange={(value) => {
|
||||
setScratch({ context: value });
|
||||
}}
|
||||
extensions={cmExtensionsContext}
|
||||
/>
|
||||
</Tab>
|
||||
);
|
||||
case TabId.OPTIONS:
|
||||
return (
|
||||
<Tab
|
||||
key={id}
|
||||
tabKey={id}
|
||||
label="Options"
|
||||
className={styles.compilerOptsTab}
|
||||
>
|
||||
<div className={styles.compilerOptsContainer}>
|
||||
<CompilerOpts
|
||||
platform={scratch.platform}
|
||||
value={scratch}
|
||||
onChange={setScratch}
|
||||
diffLabel={scratch.diff_label}
|
||||
onDiffLabelChange={(d) =>
|
||||
setScratch({ diff_label: d })
|
||||
}
|
||||
matchOverride={scratch.match_override}
|
||||
onMatchOverrideChange={(m) =>
|
||||
setScratch({ match_override: m })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Tab>
|
||||
);
|
||||
case TabId.DIFF:
|
||||
return (
|
||||
<Tab
|
||||
key={id}
|
||||
tabKey={id}
|
||||
label={
|
||||
<>
|
||||
Compilation
|
||||
{compilation && (
|
||||
<ScoreBadge
|
||||
score={
|
||||
compilation?.diff_output
|
||||
?.current_score ?? -1
|
||||
}
|
||||
maxScore={
|
||||
compilation?.diff_output
|
||||
?.max_score ?? -1
|
||||
}
|
||||
matchOverride={scratch.match_override}
|
||||
compiledSuccessfully={
|
||||
compilation?.success ?? false
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
className={styles.diffTab}
|
||||
>
|
||||
{compilation && (
|
||||
<CompilationPanel
|
||||
scratch={scratch}
|
||||
compilation={compilation}
|
||||
isCompiling={isCompiling}
|
||||
isCompilationOld={isCompilationOld}
|
||||
selectedSourceLine={selectedSourceLine}
|
||||
perSaveObj={perSaveObj}
|
||||
/>
|
||||
)}
|
||||
</Tab>
|
||||
);
|
||||
case TabId.DECOMPILATION:
|
||||
return (
|
||||
decompilationTabEnabled && (
|
||||
<Tab
|
||||
key={id}
|
||||
tabKey={id}
|
||||
label={
|
||||
<>
|
||||
Decompilation
|
||||
<TabCloseButton
|
||||
onClick={() =>
|
||||
setDecompilationTabEnabled(false)
|
||||
}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{() => <DecompilationPanel scratch={scratch} />}
|
||||
</Tab>
|
||||
)
|
||||
);
|
||||
case TabId.FAMILY:
|
||||
return (
|
||||
<Tab key={id} tabKey={id} label="Family">
|
||||
{() => <FamilyPanel scratch={scratch} />}
|
||||
</Tab>
|
||||
);
|
||||
default:
|
||||
return <Tab key={id} tabKey={id} label={id} disabled />;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (container.width) {
|
||||
const preferredLayout = getDefaultLayout(container.width, container.height)
|
||||
const preferredLayout = getDefaultLayout(
|
||||
container.width,
|
||||
container.height,
|
||||
);
|
||||
|
||||
if (layoutName !== preferredLayout) {
|
||||
setLayoutName(preferredLayout)
|
||||
setLayout(DEFAULT_LAYOUTS[preferredLayout])
|
||||
setLayoutName(preferredLayout);
|
||||
setLayout(DEFAULT_LAYOUTS[preferredLayout]);
|
||||
}
|
||||
}
|
||||
|
||||
const offlineOverlay = (
|
||||
offline ? <>
|
||||
const offlineOverlay = offline ? (
|
||||
<>
|
||||
<div className="fixed top-10 self-center rounded bg-red-8 px-3 py-2">
|
||||
<p className="text-sm">The scratch editor is in offline mode. We're attempting to reconnect to the backend – as long as this tab is open, your work is safe.</p>
|
||||
<p className="text-sm">
|
||||
The scratch editor is in offline mode. We're attempting to
|
||||
reconnect to the backend – as long as this tab is open, your
|
||||
work is safe.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
: <></>
|
||||
)
|
||||
) : (
|
||||
<></>
|
||||
);
|
||||
|
||||
const matchPercent = calculateScorePercent(lastGoodScore.current, lastGoodMaxScore.current)
|
||||
const matchPercent = calculateScorePercent(
|
||||
lastGoodScore.current,
|
||||
lastGoodMaxScore.current,
|
||||
);
|
||||
|
||||
return <div ref={container.ref} className={styles.container}>
|
||||
<ErrorBoundary>
|
||||
<ScratchMatchBanner scratch={scratch} />
|
||||
</ErrorBoundary>
|
||||
<ErrorBoundary>
|
||||
<ScratchToolbar
|
||||
compile={compile}
|
||||
isCompiling={isCompiling}
|
||||
scratch={scratch}
|
||||
setScratch={setScratch}
|
||||
saveCallback={saveCallback}
|
||||
setDecompilationTabEnabled={setDecompilationTabEnabled}
|
||||
/>
|
||||
{matchProgressBarEnabledSetting && <div className={styles.progressbar}><ScratchProgressBar matchPercent={matchPercent} /></div>}
|
||||
</ErrorBoundary>
|
||||
<ErrorBoundary>
|
||||
{layout && <ScrollContext.Provider value={sourceEditor}>
|
||||
<CustomLayout
|
||||
layout={layout}
|
||||
onChange={setLayout}
|
||||
renderTab={renderTab}
|
||||
return (
|
||||
<div ref={container.ref} className={styles.container}>
|
||||
<ErrorBoundary>
|
||||
<ScratchMatchBanner scratch={scratch} />
|
||||
</ErrorBoundary>
|
||||
<ErrorBoundary>
|
||||
<ScratchToolbar
|
||||
compile={compile}
|
||||
isCompiling={isCompiling}
|
||||
scratch={scratch}
|
||||
setScratch={setScratch}
|
||||
saveCallback={saveCallback}
|
||||
setDecompilationTabEnabled={setDecompilationTabEnabled}
|
||||
/>
|
||||
</ScrollContext.Provider>}
|
||||
</ErrorBoundary>
|
||||
{offlineOverlay}
|
||||
</div>
|
||||
{matchProgressBarEnabledSetting && (
|
||||
<div className={styles.progressbar}>
|
||||
<ScratchProgressBar matchPercent={matchPercent} />
|
||||
</div>
|
||||
)}
|
||||
</ErrorBoundary>
|
||||
<ErrorBoundary>
|
||||
{layout && (
|
||||
<ScrollContext.Provider value={sourceEditor}>
|
||||
<CustomLayout
|
||||
layout={layout}
|
||||
onChange={setLayout}
|
||||
renderTab={renderTab}
|
||||
/>
|
||||
</ScrollContext.Provider>
|
||||
)}
|
||||
</ErrorBoundary>
|
||||
{offlineOverlay}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,34 +1,38 @@
|
||||
import Link from "next/link"
|
||||
import Link from "next/link";
|
||||
|
||||
import useSWR from "swr"
|
||||
import useSWR from "swr";
|
||||
|
||||
import * as api from "@/lib/api"
|
||||
import { scratchUrl } from "@/lib/api/urls"
|
||||
import * as api from "@/lib/api";
|
||||
import { scratchUrl } from "@/lib/api/urls";
|
||||
|
||||
import DismissableBanner from "../DismissableBanner"
|
||||
import DismissableBanner from "../DismissableBanner";
|
||||
|
||||
export default function ScratchMatchBanner({ scratch }: { scratch: api.TerseScratch }) {
|
||||
const userIsYou = api.useUserIsYou()
|
||||
const { data, error } = useSWR<api.TerseScratch[]>(`${scratchUrl(scratch)}/family`, api.get, {
|
||||
refreshInterval: 60 * 1000, // 1 minute
|
||||
})
|
||||
export default function ScratchMatchBanner({
|
||||
scratch,
|
||||
}: { scratch: api.TerseScratch }) {
|
||||
const userIsYou = api.useUserIsYou();
|
||||
const { data, error } = useSWR<api.TerseScratch[]>(
|
||||
`${scratchUrl(scratch)}/family`,
|
||||
api.get,
|
||||
{
|
||||
refreshInterval: 60 * 1000, // 1 minute
|
||||
},
|
||||
);
|
||||
|
||||
// Consciously not including match_override here, since it's not really banner-worthy
|
||||
const match = data?.find(s => s.score === 0 && s.slug !== scratch.slug)
|
||||
const match = data?.find((s) => s.score === 0 && s.slug !== scratch.slug);
|
||||
|
||||
if (error)
|
||||
throw error
|
||||
if (error) throw error;
|
||||
|
||||
if (scratch.score === 0 || !match)
|
||||
return null
|
||||
if (scratch.score === 0 || !match) return null;
|
||||
|
||||
let message = "This function has been matched"
|
||||
if (userIsYou(match.owner))
|
||||
message += " by you, elsewhere"
|
||||
else if (match.owner)
|
||||
message += ` by ${match.owner.username}`
|
||||
let message = "This function has been matched";
|
||||
if (userIsYou(match.owner)) message += " by you, elsewhere";
|
||||
else if (match.owner) message += ` by ${match.owner.username}`;
|
||||
|
||||
return <DismissableBanner>
|
||||
{message}. <Link href={scratchUrl(match)}>View match</Link>
|
||||
</DismissableBanner>
|
||||
return (
|
||||
<DismissableBanner>
|
||||
{message}. <Link href={scratchUrl(match)}>View match</Link>
|
||||
</DismissableBanner>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import * as Progress from "@radix-ui/react-progress"
|
||||
import * as Progress from "@radix-ui/react-progress";
|
||||
|
||||
import styles from "./ScratchProgressbar.module.scss"
|
||||
import styles from "./ScratchProgressbar.module.scss";
|
||||
|
||||
export default function ScratchProgressBar({ matchPercent }: {matchPercent: number}) {
|
||||
return <Progress.Root className={styles.ProgressRoot} value={matchPercent}>
|
||||
<Progress.Indicator
|
||||
className={styles.ProgressIndicator}
|
||||
style={{ transform: `translateX(-${100 - matchPercent}%)`, backgroundColor: `hsl(271, ${matchPercent * 0.91}%, ${30 + (matchPercent * 0.35)}%)` }}
|
||||
/>
|
||||
</Progress.Root>
|
||||
export default function ScratchProgressBar({
|
||||
matchPercent,
|
||||
}: { matchPercent: number }) {
|
||||
return (
|
||||
<Progress.Root className={styles.ProgressRoot} value={matchPercent}>
|
||||
<Progress.Indicator
|
||||
className={styles.ProgressIndicator}
|
||||
style={{
|
||||
transform: `translateX(-${100 - matchPercent}%)`,
|
||||
backgroundColor: `hsl(271, ${matchPercent * 0.91}%, ${30 + matchPercent * 0.35}%)`,
|
||||
}}
|
||||
/>
|
||||
</Progress.Root>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,169 +1,199 @@
|
||||
import { useEffect, useRef, useState, type FC } from "react"
|
||||
import { useEffect, useRef, useState, type FC } from "react";
|
||||
|
||||
import Link from "next/link"
|
||||
import Link from "next/link";
|
||||
|
||||
import { DownloadIcon, FileIcon, IterationsIcon, RepoForkedIcon, SyncIcon, TrashIcon, UploadIcon } from "@primer/octicons-react"
|
||||
import classNames from "classnames"
|
||||
import ContentEditable from "react-contenteditable"
|
||||
import {
|
||||
DownloadIcon,
|
||||
FileIcon,
|
||||
IterationsIcon,
|
||||
RepoForkedIcon,
|
||||
SyncIcon,
|
||||
TrashIcon,
|
||||
UploadIcon,
|
||||
} from "@primer/octicons-react";
|
||||
import classNames from "classnames";
|
||||
import ContentEditable from "react-contenteditable";
|
||||
|
||||
import TimeAgo from "@/components/TimeAgo"
|
||||
import * as api from "@/lib/api"
|
||||
import { scratchUrl } from "@/lib/api/urls"
|
||||
import { useSize } from "@/lib/hooks"
|
||||
import TimeAgo from "@/components/TimeAgo";
|
||||
import * as api from "@/lib/api";
|
||||
import { scratchUrl } from "@/lib/api/urls";
|
||||
import { useSize } from "@/lib/hooks";
|
||||
|
||||
import Breadcrumbs from "../Breadcrumbs"
|
||||
import Nav from "../Nav"
|
||||
import PlatformLink from "../PlatformLink"
|
||||
import { SpecialKey, useShortcut } from "../Shortcut"
|
||||
import UserAvatar from "../user/UserAvatar"
|
||||
import Breadcrumbs from "../Breadcrumbs";
|
||||
import Nav from "../Nav";
|
||||
import PlatformLink from "../PlatformLink";
|
||||
import { SpecialKey, useShortcut } from "../Shortcut";
|
||||
import UserAvatar from "../user/UserAvatar";
|
||||
|
||||
import useFuzzySaveCallback, { FuzzySaveAction } from "./hooks/useFuzzySaveCallback"
|
||||
import styles from "./ScratchToolbar.module.scss"
|
||||
import useFuzzySaveCallback, {
|
||||
FuzzySaveAction,
|
||||
} from "./hooks/useFuzzySaveCallback";
|
||||
import styles from "./ScratchToolbar.module.scss";
|
||||
|
||||
const ACTIVE_MS = 1000 * 60
|
||||
const ACTIVE_MS = 1000 * 60;
|
||||
|
||||
// Prevents XSS
|
||||
function htmlTextOnly(html: string): string {
|
||||
return html.replace(/</g, "<").replace(/>/g, ">")
|
||||
return html.replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
function exportScratchZip(scratch: api.Scratch) {
|
||||
const url = api.normalizeUrl(`${scratchUrl(scratch)}/export`)
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = `${scratch.name}.zip`
|
||||
a.click()
|
||||
const url = api.normalizeUrl(`${scratchUrl(scratch)}/export`);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `${scratch.name}.zip`;
|
||||
a.click();
|
||||
}
|
||||
|
||||
async function deleteScratch(scratch: api.Scratch) {
|
||||
await api.delete_(scratchUrl(scratch), {})
|
||||
await api.delete_(scratchUrl(scratch), {});
|
||||
|
||||
window.location.href = scratch.project ? `/${scratch.project}` : "/"
|
||||
window.location.href = scratch.project ? `/${scratch.project}` : "/";
|
||||
}
|
||||
|
||||
function EditTimeAgo({ date }: { date: string }) {
|
||||
const isActive = (Date.now() - (new Date(date)).getTime()) < ACTIVE_MS
|
||||
const isActive = Date.now() - new Date(date).getTime() < ACTIVE_MS;
|
||||
|
||||
// Rerender after ACTIVE_MS has elapsed if isActive=true
|
||||
const [, forceUpdate] = useState({})
|
||||
const [, forceUpdate] = useState({});
|
||||
useEffect(() => {
|
||||
if (isActive) {
|
||||
const interval = setTimeout(() => forceUpdate({}), ACTIVE_MS)
|
||||
return () => clearInterval(interval)
|
||||
const interval = setTimeout(() => forceUpdate({}), ACTIVE_MS);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [isActive])
|
||||
}, [isActive]);
|
||||
|
||||
return <span className={styles.lastEditTime}>
|
||||
{isActive ? <>
|
||||
Active now
|
||||
</> : <>
|
||||
<TimeAgo date={date} />
|
||||
</>}
|
||||
</span>
|
||||
return (
|
||||
<span className={styles.lastEditTime}>
|
||||
{isActive ? (
|
||||
<>Active now</>
|
||||
) : (
|
||||
<>
|
||||
<TimeAgo date={date} />
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ScratchName({ name, onChange }: { name: string, onChange?: (name: string) => void }) {
|
||||
const [isEditing, setEditing] = useState(false)
|
||||
const editableRef = useRef<HTMLDivElement>()
|
||||
function ScratchName({
|
||||
name,
|
||||
onChange,
|
||||
}: { name: string; onChange?: (name: string) => void }) {
|
||||
const [isEditing, setEditing] = useState(false);
|
||||
const editableRef = useRef<HTMLDivElement>();
|
||||
|
||||
useEffect(() => {
|
||||
const el = editableRef.current
|
||||
const el = editableRef.current;
|
||||
|
||||
if (el) {
|
||||
const range = document.createRange()
|
||||
range.selectNodeContents(el)
|
||||
const sel = window.getSelection()
|
||||
sel.removeAllRanges()
|
||||
sel.addRange(range)
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(el);
|
||||
const sel = window.getSelection();
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
}
|
||||
}, [isEditing])
|
||||
}, [isEditing]);
|
||||
|
||||
if (isEditing) {
|
||||
return <ContentEditable
|
||||
innerRef={editableRef}
|
||||
tagName="div"
|
||||
html={htmlTextOnly(name)}
|
||||
spellCheck={false}
|
||||
className={styles.name}
|
||||
return (
|
||||
<ContentEditable
|
||||
innerRef={editableRef}
|
||||
tagName="div"
|
||||
html={htmlTextOnly(name)}
|
||||
spellCheck={false}
|
||||
className={styles.name}
|
||||
onChange={(evt) => {
|
||||
const name = evt.currentTarget.innerText as string;
|
||||
if (name.length !== 0) onChange(name);
|
||||
}}
|
||||
onPaste={(evt) => {
|
||||
// Only allow pasting text, rather than any HTML. This is redundant due
|
||||
// to htmlTextOnly but it's nice not to show "<img>" when you paste an image.
|
||||
|
||||
onChange={evt => {
|
||||
const name = evt.currentTarget.innerText as string
|
||||
if (name.length !== 0)
|
||||
onChange(name)
|
||||
}}
|
||||
evt.preventDefault();
|
||||
const text = evt.clipboardData.getData("text");
|
||||
|
||||
onPaste={evt => {
|
||||
// Only allow pasting text, rather than any HTML. This is redundant due
|
||||
// to htmlTextOnly but it's nice not to show "<img>" when you paste an image.
|
||||
|
||||
evt.preventDefault()
|
||||
const text = evt.clipboardData.getData("text")
|
||||
|
||||
// note: we're using document.execCommand, which is deprecated,
|
||||
// but its no big deal if it doesn't work.
|
||||
document.execCommand("insertText", false, text)
|
||||
}}
|
||||
|
||||
onBlur={() => setEditing(false)}
|
||||
|
||||
onKeyDown={evt => {
|
||||
if (evt.key === "Enter") {
|
||||
evt.preventDefault()
|
||||
setEditing(false)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
// note: we're using document.execCommand, which is deprecated,
|
||||
// but its no big deal if it doesn't work.
|
||||
document.execCommand("insertText", false, text);
|
||||
}}
|
||||
onBlur={() => setEditing(false)}
|
||||
onKeyDown={(evt) => {
|
||||
if (evt.key === "Enter") {
|
||||
evt.preventDefault();
|
||||
setEditing(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return <div
|
||||
className={classNames(styles.name, { [styles.editable]: !!onChange })}
|
||||
onClick={() => {
|
||||
if (onChange)
|
||||
setEditing(true)
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</div>
|
||||
return (
|
||||
<div
|
||||
className={classNames(styles.name, {
|
||||
[styles.editable]: !!onChange,
|
||||
})}
|
||||
onClick={() => {
|
||||
if (onChange) setEditing(true);
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function Actions({ isCompiling, compile, scratch, setScratch, saveCallback, setDecompilationTabEnabled }: Props) {
|
||||
const userIsYou = api.useUserIsYou()
|
||||
const forkScratch = api.useForkScratchAndGo(scratch)
|
||||
const [fuzzySaveAction, fuzzySaveScratch] = useFuzzySaveCallback(scratch, setScratch)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [isForking, setIsForking] = useState(false)
|
||||
const canSave = scratch.owner && userIsYou(scratch.owner)
|
||||
function Actions({
|
||||
isCompiling,
|
||||
compile,
|
||||
scratch,
|
||||
setScratch,
|
||||
saveCallback,
|
||||
setDecompilationTabEnabled,
|
||||
}: Props) {
|
||||
const userIsYou = api.useUserIsYou();
|
||||
const forkScratch = api.useForkScratchAndGo(scratch);
|
||||
const [fuzzySaveAction, fuzzySaveScratch] = useFuzzySaveCallback(
|
||||
scratch,
|
||||
setScratch,
|
||||
);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isForking, setIsForking] = useState(false);
|
||||
const canSave = scratch.owner && userIsYou(scratch.owner);
|
||||
|
||||
const platform = api.usePlatform(scratch.platform)
|
||||
const platform = api.usePlatform(scratch.platform);
|
||||
|
||||
const fuzzyShortcut = useShortcut([SpecialKey.CTRL_COMMAND, "S"], async () => {
|
||||
setIsSaving(true)
|
||||
await fuzzySaveScratch()
|
||||
setIsSaving(false)
|
||||
saveCallback()
|
||||
})
|
||||
const fuzzyShortcut = useShortcut(
|
||||
[SpecialKey.CTRL_COMMAND, "S"],
|
||||
async () => {
|
||||
setIsSaving(true);
|
||||
await fuzzySaveScratch();
|
||||
setIsSaving(false);
|
||||
saveCallback();
|
||||
},
|
||||
);
|
||||
|
||||
const compileShortcut = useShortcut([SpecialKey.CTRL_COMMAND, "J"], () => {
|
||||
compile()
|
||||
})
|
||||
compile();
|
||||
});
|
||||
|
||||
const isAdmin = api.useThisUserIsAdmin()
|
||||
const isAdmin = api.useThisUserIsAdmin();
|
||||
|
||||
return (
|
||||
<ul className={styles.actions} aria-label="Scratch actions">
|
||||
<li>
|
||||
<Link href="/new">
|
||||
|
||||
<FileIcon />New
|
||||
<FileIcon />
|
||||
New
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
onClick={async () => {
|
||||
setIsSaving(true)
|
||||
await fuzzySaveScratch()
|
||||
setIsSaving(false)
|
||||
saveCallback()
|
||||
setIsSaving(true);
|
||||
await fuzzySaveScratch();
|
||||
setIsSaving(false);
|
||||
saveCallback();
|
||||
}}
|
||||
disabled={!canSave || isSaving}
|
||||
title={fuzzyShortcut}
|
||||
@@ -175,32 +205,45 @@ function Actions({ isCompiling, compile, scratch, setScratch, saveCallback, setD
|
||||
<li>
|
||||
<button
|
||||
onClick={async () => {
|
||||
setIsForking(true)
|
||||
await forkScratch()
|
||||
setIsForking(false)
|
||||
saveCallback()
|
||||
setIsForking(true);
|
||||
await forkScratch();
|
||||
setIsForking(false);
|
||||
saveCallback();
|
||||
}}
|
||||
disabled={isForking}
|
||||
title={fuzzySaveAction === FuzzySaveAction.FORK ? fuzzyShortcut : undefined}
|
||||
title={
|
||||
fuzzySaveAction === FuzzySaveAction.FORK
|
||||
? fuzzyShortcut
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<RepoForkedIcon />
|
||||
Fork
|
||||
</button>
|
||||
</li>
|
||||
{((scratch.owner && userIsYou(scratch.owner)) || isAdmin) && <li>
|
||||
<button onClick={event => {
|
||||
if (event.shiftKey || confirm("Are you sure you want to delete this scratch? This action cannot be undone.")) {
|
||||
deleteScratch(scratch)
|
||||
}
|
||||
}}>
|
||||
<TrashIcon />
|
||||
{((scratch.owner && userIsYou(scratch.owner)) || isAdmin) && (
|
||||
<li>
|
||||
<button
|
||||
onClick={(event) => {
|
||||
if (
|
||||
event.shiftKey ||
|
||||
confirm(
|
||||
"Are you sure you want to delete this scratch? This action cannot be undone.",
|
||||
)
|
||||
) {
|
||||
deleteScratch(scratch);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<TrashIcon />
|
||||
Delete
|
||||
</button>
|
||||
</li>}
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
<li>
|
||||
<button onClick={() => exportScratchZip(scratch)}>
|
||||
<DownloadIcon />
|
||||
Export
|
||||
Export
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
@@ -213,16 +256,16 @@ function Actions({ isCompiling, compile, scratch, setScratch, saveCallback, setD
|
||||
Compile
|
||||
</button>
|
||||
</li>
|
||||
{platform?.has_decompiler &&
|
||||
{platform?.has_decompiler && (
|
||||
<li>
|
||||
<button onClick={() => setDecompilationTabEnabled(true)}>
|
||||
<IterationsIcon />
|
||||
Decompile
|
||||
</button>
|
||||
</li>
|
||||
}
|
||||
)}
|
||||
</ul>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
enum ActionsLocation {
|
||||
@@ -231,73 +274,104 @@ enum ActionsLocation {
|
||||
}
|
||||
|
||||
function useActionsLocation(): [ActionsLocation, FC<Props>] {
|
||||
const inNavActions = useSize<HTMLDivElement>()
|
||||
const inNavActions = useSize<HTMLDivElement>();
|
||||
|
||||
let location = ActionsLocation.BELOW_NAV
|
||||
let location = ActionsLocation.BELOW_NAV;
|
||||
|
||||
const el = inNavActions.ref.current
|
||||
const el = inNavActions.ref.current;
|
||||
if (el) {
|
||||
if (el.clientWidth === el.scrollWidth) {
|
||||
location = ActionsLocation.IN_NAV
|
||||
location = ActionsLocation.IN_NAV;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
location,
|
||||
(props: Props) => <div
|
||||
ref={inNavActions.ref}
|
||||
aria-hidden={location !== ActionsLocation.IN_NAV}
|
||||
className={styles.inNavActionsContainer}
|
||||
>
|
||||
<Actions {...props} />
|
||||
</div>,
|
||||
]
|
||||
(props: Props) => (
|
||||
<div
|
||||
ref={inNavActions.ref}
|
||||
aria-hidden={location !== ActionsLocation.IN_NAV}
|
||||
className={styles.inNavActionsContainer}
|
||||
>
|
||||
<Actions {...props} />
|
||||
</div>
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
export type Props = {
|
||||
isCompiling: boolean
|
||||
compile: () => Promise<void>
|
||||
scratch: Readonly<api.Scratch>
|
||||
setScratch: (scratch: Partial<api.Scratch>) => void
|
||||
saveCallback: () => void
|
||||
setDecompilationTabEnabled: (enabled: boolean) => void
|
||||
}
|
||||
isCompiling: boolean;
|
||||
compile: () => Promise<void>;
|
||||
scratch: Readonly<api.Scratch>;
|
||||
setScratch: (scratch: Partial<api.Scratch>) => void;
|
||||
saveCallback: () => void;
|
||||
setDecompilationTabEnabled: (enabled: boolean) => void;
|
||||
};
|
||||
|
||||
export default function ScratchToolbar(props: Props) {
|
||||
const { scratch, setScratch } = props
|
||||
const userIsYou = api.useUserIsYou()
|
||||
const { scratch, setScratch } = props;
|
||||
const userIsYou = api.useUserIsYou();
|
||||
|
||||
const [actionsLocation, InNavActions] = useActionsLocation()
|
||||
const [actionsLocation, InNavActions] = useActionsLocation();
|
||||
|
||||
return <>
|
||||
<Nav>
|
||||
<div className={styles.container}>
|
||||
<Breadcrumbs className={styles.breadcrumbs} pages={[
|
||||
scratch.owner && {
|
||||
label: <div className={styles.owner}>
|
||||
<UserAvatar user={scratch.owner} className={styles.ownerAvatar} />
|
||||
<span className={styles.ownerName}>
|
||||
{scratch.owner.username}
|
||||
</span>
|
||||
</div>,
|
||||
href: !scratch.owner.is_anonymous && `/u/${scratch.owner.username}`,
|
||||
},
|
||||
{
|
||||
label: <div className={styles.iconNamePair}>
|
||||
<PlatformLink scratch={scratch} size={20} />
|
||||
<ScratchName
|
||||
name={scratch.name}
|
||||
onChange={userIsYou(scratch.owner) && (name => setScratch({ name }))}
|
||||
/>
|
||||
<EditTimeAgo date={scratch.last_updated} />
|
||||
</div>,
|
||||
},
|
||||
].filter(Boolean)} />
|
||||
<InNavActions {...props} />
|
||||
</div>
|
||||
</Nav>
|
||||
{actionsLocation === ActionsLocation.BELOW_NAV && <div className={classNames(styles.belowNavActionsContainer, "border-gray-6 border-b")}>
|
||||
<Actions {...props} />
|
||||
</div>}
|
||||
</>
|
||||
return (
|
||||
<>
|
||||
<Nav>
|
||||
<div className={styles.container}>
|
||||
<Breadcrumbs
|
||||
className={styles.breadcrumbs}
|
||||
pages={[
|
||||
scratch.owner && {
|
||||
label: (
|
||||
<div className={styles.owner}>
|
||||
<UserAvatar
|
||||
user={scratch.owner}
|
||||
className={styles.ownerAvatar}
|
||||
/>
|
||||
<span className={styles.ownerName}>
|
||||
{scratch.owner.username}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
href:
|
||||
!scratch.owner.is_anonymous &&
|
||||
`/u/${scratch.owner.username}`,
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<div className={styles.iconNamePair}>
|
||||
<PlatformLink
|
||||
scratch={scratch}
|
||||
size={20}
|
||||
/>
|
||||
<ScratchName
|
||||
name={scratch.name}
|
||||
onChange={
|
||||
userIsYou(scratch.owner) &&
|
||||
((name) => setScratch({ name }))
|
||||
}
|
||||
/>
|
||||
<EditTimeAgo
|
||||
date={scratch.last_updated}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
].filter(Boolean)}
|
||||
/>
|
||||
<InNavActions {...props} />
|
||||
</div>
|
||||
</Nav>
|
||||
{actionsLocation === ActionsLocation.BELOW_NAV && (
|
||||
<div
|
||||
className={classNames(
|
||||
styles.belowNavActionsContainer,
|
||||
"border-gray-6 border-b",
|
||||
)}
|
||||
>
|
||||
<Actions {...props} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,27 +1,38 @@
|
||||
import { useMemo, useState } from "react"
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import Link from "next/link"
|
||||
import Link from "next/link";
|
||||
|
||||
import classNames from "classnames"
|
||||
import useSWR from "swr"
|
||||
import classNames from "classnames";
|
||||
import useSWR from "swr";
|
||||
|
||||
import { get } from "@/lib/api/request"
|
||||
import type { TerseScratch } from "@/lib/api/types"
|
||||
import { scratchUrl } from "@/lib/api/urls"
|
||||
import { get } from "@/lib/api/request";
|
||||
import type { TerseScratch } from "@/lib/api/types";
|
||||
import { scratchUrl } from "@/lib/api/urls";
|
||||
|
||||
import { getScoreAsFraction, getScoreText } from "../ScoreBadge"
|
||||
import Sort, { SortMode, compareScratchScores, produceSortFunction } from "../Sort"
|
||||
import UserLink from "../user/UserLink"
|
||||
import { getScoreAsFraction, getScoreText } from "../ScoreBadge";
|
||||
import Sort, {
|
||||
SortMode,
|
||||
compareScratchScores,
|
||||
produceSortFunction,
|
||||
} from "../Sort";
|
||||
import UserLink from "../user/UserLink";
|
||||
|
||||
function useFamily(scratch: TerseScratch) {
|
||||
const { data: family } = useSWR<TerseScratch[]>(`${scratchUrl(scratch)}/family`, get, {
|
||||
suspense: true,
|
||||
})
|
||||
const { data: family } = useSWR<TerseScratch[]>(
|
||||
`${scratchUrl(scratch)}/family`,
|
||||
get,
|
||||
{
|
||||
suspense: true,
|
||||
},
|
||||
);
|
||||
|
||||
const [sortMode, setSortMode] = useState(SortMode.NEWEST_FIRST)
|
||||
const sorted = useMemo(() => [...family].sort(produceSortFunction(sortMode)), [family, sortMode])
|
||||
const [sortMode, setSortMode] = useState(SortMode.NEWEST_FIRST);
|
||||
const sorted = useMemo(
|
||||
() => [...family].sort(produceSortFunction(sortMode)),
|
||||
[family, sortMode],
|
||||
);
|
||||
|
||||
return { sorted, sortMode, setSortMode }
|
||||
return { sorted, sortMode, setSortMode };
|
||||
}
|
||||
|
||||
function FamilyMember({
|
||||
@@ -29,61 +40,78 @@ function FamilyMember({
|
||||
isCurrent,
|
||||
isBetter,
|
||||
}: {
|
||||
scratch: TerseScratch
|
||||
isCurrent: boolean
|
||||
isBetter: boolean
|
||||
scratch: TerseScratch;
|
||||
isCurrent: boolean;
|
||||
isBetter: boolean;
|
||||
}) {
|
||||
return <div className="flex">
|
||||
<UserLink user={scratch.owner} />
|
||||
<span className="mx-2 text-gray-8">/</span>
|
||||
{isCurrent ? <span className="font-medium text-gray-11">
|
||||
This scratch
|
||||
</span> : <Link href={scratchUrl(scratch)} className="font-medium">
|
||||
{scratch.name}
|
||||
</Link>}
|
||||
<div className="grow" />
|
||||
<div
|
||||
title={getScoreAsFraction(scratch.score, scratch.max_score)}
|
||||
className={classNames({ "text-gray-11": !isBetter })}
|
||||
>
|
||||
{getScoreText(scratch.score, scratch.max_score, scratch.match_override)}
|
||||
return (
|
||||
<div className="flex">
|
||||
<UserLink user={scratch.owner} />
|
||||
<span className="mx-2 text-gray-8">/</span>
|
||||
{isCurrent ? (
|
||||
<span className="font-medium text-gray-11">This scratch</span>
|
||||
) : (
|
||||
<Link href={scratchUrl(scratch)} className="font-medium">
|
||||
{scratch.name}
|
||||
</Link>
|
||||
)}
|
||||
<div className="grow" />
|
||||
<div
|
||||
title={getScoreAsFraction(scratch.score, scratch.max_score)}
|
||||
className={classNames({ "text-gray-11": !isBetter })}
|
||||
>
|
||||
{getScoreText(
|
||||
scratch.score,
|
||||
scratch.max_score,
|
||||
scratch.match_override,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SortableFamilyList({ scratch }: { scratch: TerseScratch }) {
|
||||
const family = useFamily(scratch)
|
||||
export default function SortableFamilyList({
|
||||
scratch,
|
||||
}: { scratch: TerseScratch }) {
|
||||
const family = useFamily(scratch);
|
||||
|
||||
if (family.sorted.length <= 1) {
|
||||
return <div className="flex h-full items-center justify-center">
|
||||
<div className="max-w-prose text-center">
|
||||
<div className="mb-2 text-xl">
|
||||
No parents or forks
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="max-w-prose text-center">
|
||||
<div className="mb-2 text-xl">No parents or forks</div>
|
||||
<p className="text-gray-11">
|
||||
This scratch has no family members. It's the only
|
||||
attempt at this function.
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-gray-11">
|
||||
This scratch has no family members.
|
||||
It's the only attempt at this function.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <div>
|
||||
<div className="mb-4 flex items-center">
|
||||
<div>
|
||||
{family.sorted.length} family members
|
||||
</div>
|
||||
<div className="grow" />
|
||||
<Sort sortMode={family.sortMode} setSortMode={family.setSortMode} />
|
||||
</div>
|
||||
<ol>
|
||||
{family.sorted.map(member => <li key={scratchUrl(member)} className="mb-2">
|
||||
<FamilyMember
|
||||
scratch={member}
|
||||
isCurrent={scratchUrl(member) === scratchUrl(scratch)}
|
||||
isBetter={compareScratchScores(member, scratch) < 0}
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4 flex items-center">
|
||||
<div>{family.sorted.length} family members</div>
|
||||
<div className="grow" />
|
||||
<Sort
|
||||
sortMode={family.sortMode}
|
||||
setSortMode={family.setSortMode}
|
||||
/>
|
||||
</li>)}
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
<ol>
|
||||
{family.sorted.map((member) => (
|
||||
<li key={scratchUrl(member)} className="mb-2">
|
||||
<FamilyMember
|
||||
scratch={member}
|
||||
isCurrent={
|
||||
scratchUrl(member) === scratchUrl(scratch)
|
||||
}
|
||||
isBetter={compareScratchScores(member, scratch) < 0}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback } from "react"
|
||||
import { useCallback } from "react";
|
||||
|
||||
import * as api from "@/lib/api"
|
||||
import * as api from "@/lib/api";
|
||||
|
||||
export enum FuzzySaveAction {
|
||||
SAVE = 0,
|
||||
@@ -12,28 +12,28 @@ export default function useFuzzySaveCallback(
|
||||
scratch: api.Scratch,
|
||||
setScratch: (partial: Partial<api.Scratch>) => void,
|
||||
): [FuzzySaveAction, () => Promise<void>] {
|
||||
const saveScratch = api.useSaveScratch(scratch)
|
||||
const forkScratch = api.useForkScratchAndGo(scratch)
|
||||
const userIsYou = api.useUserIsYou()
|
||||
const saveScratch = api.useSaveScratch(scratch);
|
||||
const forkScratch = api.useForkScratchAndGo(scratch);
|
||||
const userIsYou = api.useUserIsYou();
|
||||
|
||||
let action = FuzzySaveAction.NONE
|
||||
let action = FuzzySaveAction.NONE;
|
||||
if (userIsYou(scratch.owner)) {
|
||||
action = FuzzySaveAction.SAVE
|
||||
action = FuzzySaveAction.SAVE;
|
||||
} else {
|
||||
action = FuzzySaveAction.FORK
|
||||
action = FuzzySaveAction.FORK;
|
||||
}
|
||||
|
||||
return [
|
||||
action,
|
||||
useCallback(async () => {
|
||||
switch (action) {
|
||||
case FuzzySaveAction.SAVE:
|
||||
setScratch(await saveScratch())
|
||||
break
|
||||
case FuzzySaveAction.FORK:
|
||||
await forkScratch()
|
||||
break
|
||||
case FuzzySaveAction.SAVE:
|
||||
setScratch(await saveScratch());
|
||||
break;
|
||||
case FuzzySaveAction.FORK:
|
||||
await forkScratch();
|
||||
break;
|
||||
}
|
||||
}, [action, forkScratch, saveScratch, setScratch]),
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,75 +1,99 @@
|
||||
import { type MutableRefObject, useEffect, useState } from "react"
|
||||
import { type MutableRefObject, useEffect, useState } from "react";
|
||||
|
||||
import type { ClangdStdioTransport, CompileCommands } from "@clangd-wasm/clangd-wasm"
|
||||
import { StateEffect } from "@codemirror/state"
|
||||
import type { EditorView } from "codemirror"
|
||||
import type {
|
||||
ClangdStdioTransport,
|
||||
CompileCommands,
|
||||
} from "@clangd-wasm/clangd-wasm";
|
||||
import { StateEffect } from "@codemirror/state";
|
||||
import type { EditorView } from "codemirror";
|
||||
|
||||
import type * as api from "@/lib/api"
|
||||
import { LanguageServerClient, languageServerWithTransport } from "@/lib/codemirror/languageServer"
|
||||
import type * as api from "@/lib/api";
|
||||
import {
|
||||
LanguageServerClient,
|
||||
languageServerWithTransport,
|
||||
} from "@/lib/codemirror/languageServer";
|
||||
|
||||
export default function useLanguageServer(enabled: boolean, scratch: api.Scratch, sourceEditor: MutableRefObject<EditorView>, contextEditor: MutableRefObject<EditorView>) {
|
||||
const [initialScratchState, setInitialScratchState] = useState<api.Scratch>(undefined)
|
||||
const [defaultClangFormat, setDefaultClangFormat] = useState<string>(undefined)
|
||||
export default function useLanguageServer(
|
||||
enabled: boolean,
|
||||
scratch: api.Scratch,
|
||||
sourceEditor: MutableRefObject<EditorView>,
|
||||
contextEditor: MutableRefObject<EditorView>,
|
||||
) {
|
||||
const [initialScratchState, setInitialScratchState] =
|
||||
useState<api.Scratch>(undefined);
|
||||
const [defaultClangFormat, setDefaultClangFormat] =
|
||||
useState<string>(undefined);
|
||||
|
||||
const [ClangdStdioTransportModule, setClangdStdioTransportModule] = useState<typeof ClangdStdioTransport>(undefined)
|
||||
const [ClangdStdioTransportModule, setClangdStdioTransportModule] =
|
||||
useState<typeof ClangdStdioTransport>(undefined);
|
||||
|
||||
const [saveSource, setSaveSource] = useState<(source: string) => Promise<void>>(undefined)
|
||||
const [saveContext, setSaveContext] = useState<(context: string) => Promise<void>>(undefined)
|
||||
const [saveSource, setSaveSource] =
|
||||
useState<(source: string) => Promise<void>>(undefined);
|
||||
const [saveContext, setSaveContext] =
|
||||
useState<(context: string) => Promise<void>>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
const loadClangdModule = async () => {
|
||||
if (!enabled) return
|
||||
if (!(scratch.language === "C" || scratch.language === "C++")) return
|
||||
if (!enabled) return;
|
||||
if (!(scratch.language === "C" || scratch.language === "C++"))
|
||||
return;
|
||||
|
||||
const { ClangdStdioTransport } = await import("@clangd-wasm/clangd-wasm")
|
||||
setClangdStdioTransportModule(() => ClangdStdioTransport)
|
||||
}
|
||||
const { ClangdStdioTransport } = await import(
|
||||
"@clangd-wasm/clangd-wasm"
|
||||
);
|
||||
setClangdStdioTransportModule(() => ClangdStdioTransport);
|
||||
};
|
||||
|
||||
loadClangdModule()
|
||||
}, [scratch.language, enabled])
|
||||
loadClangdModule();
|
||||
}, [scratch.language, enabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialScratchState) {
|
||||
setInitialScratchState(scratch)
|
||||
setInitialScratchState(scratch);
|
||||
}
|
||||
}, [scratch, initialScratchState])
|
||||
}, [scratch, initialScratchState]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(new URL("./default-clang-format.yaml", import.meta.url))
|
||||
.then(res => res.text())
|
||||
.then(setDefaultClangFormat)
|
||||
}, [])
|
||||
.then((res) => res.text())
|
||||
.then(setDefaultClangFormat);
|
||||
}, []);
|
||||
|
||||
// We break this out into a seperate effect from the module loading
|
||||
// because if we had _lsClient defined inside an async function, we wouldn't be
|
||||
// able to reference it inside of the destructor.
|
||||
useEffect(() => {
|
||||
if (!ClangdStdioTransportModule) return
|
||||
if (!initialScratchState) return
|
||||
if (!defaultClangFormat) return
|
||||
if (!ClangdStdioTransportModule) return;
|
||||
if (!initialScratchState) return;
|
||||
if (!defaultClangFormat) return;
|
||||
|
||||
const languageId = {
|
||||
"C": "c",
|
||||
C: "c",
|
||||
"C++": "cpp",
|
||||
}[initialScratchState.language]
|
||||
}[initialScratchState.language];
|
||||
|
||||
const sourceFilename = `source.${languageId}`
|
||||
const contextFilename = `context.${languageId}`
|
||||
const sourceFilename = `source.${languageId}`;
|
||||
const contextFilename = `context.${languageId}`;
|
||||
|
||||
const compileCommands: CompileCommands = [
|
||||
{
|
||||
directory: "/",
|
||||
file: sourceFilename,
|
||||
arguments: ["clang", sourceFilename, "-include", contextFilename],
|
||||
arguments: [
|
||||
"clang",
|
||||
sourceFilename,
|
||||
"-include",
|
||||
contextFilename,
|
||||
],
|
||||
},
|
||||
]
|
||||
];
|
||||
|
||||
const initialFileState: Record<string, string> = {
|
||||
".clang-format": defaultClangFormat,
|
||||
}
|
||||
};
|
||||
|
||||
initialFileState[sourceFilename] = initialScratchState.source_code
|
||||
initialFileState[contextFilename] = initialScratchState.context
|
||||
initialFileState[sourceFilename] = initialScratchState.source_code;
|
||||
initialFileState[contextFilename] = initialScratchState.context;
|
||||
|
||||
const _lsClient = new LanguageServerClient({
|
||||
transport: new ClangdStdioTransportModule({
|
||||
@@ -82,7 +106,7 @@ export default function useLanguageServer(enabled: boolean, scratch: api.Scratch
|
||||
workspaceFolders: null,
|
||||
documentUri: null,
|
||||
languageId,
|
||||
})
|
||||
});
|
||||
|
||||
const [sourceLsExtension, _saveSource] = languageServerWithTransport({
|
||||
client: _lsClient,
|
||||
@@ -91,7 +115,7 @@ export default function useLanguageServer(enabled: boolean, scratch: api.Scratch
|
||||
workspaceFolders: null,
|
||||
documentUri: `file:///${sourceFilename}`,
|
||||
languageId,
|
||||
})
|
||||
});
|
||||
|
||||
const [contextLsExtension, _saveContext] = languageServerWithTransport({
|
||||
client: _lsClient,
|
||||
@@ -100,32 +124,39 @@ export default function useLanguageServer(enabled: boolean, scratch: api.Scratch
|
||||
workspaceFolders: null,
|
||||
documentUri: `file:///${contextFilename}`,
|
||||
languageId,
|
||||
})
|
||||
});
|
||||
|
||||
// TODO: return the codemirror extensions instead of hotpatching them in?
|
||||
// Given the async nature of the extension being ready, it'd require updating the Codemirror
|
||||
// component to support inserting extensions when the extension prop changes
|
||||
sourceEditor.current?.dispatch({ effects: StateEffect.appendConfig.of(sourceLsExtension) })
|
||||
contextEditor.current?.dispatch({ effects: StateEffect.appendConfig.of(contextLsExtension) })
|
||||
sourceEditor.current?.dispatch({
|
||||
effects: StateEffect.appendConfig.of(sourceLsExtension),
|
||||
});
|
||||
contextEditor.current?.dispatch({
|
||||
effects: StateEffect.appendConfig.of(contextLsExtension),
|
||||
});
|
||||
|
||||
setSaveSource(() => _saveSource)
|
||||
setSaveContext(() => _saveContext)
|
||||
setSaveSource(() => _saveSource);
|
||||
setSaveContext(() => _saveContext);
|
||||
|
||||
return () => {
|
||||
_lsClient.exit()
|
||||
}
|
||||
|
||||
}, [ClangdStdioTransportModule, initialScratchState, defaultClangFormat, sourceEditor, contextEditor])
|
||||
_lsClient.exit();
|
||||
};
|
||||
}, [
|
||||
ClangdStdioTransportModule,
|
||||
initialScratchState,
|
||||
defaultClangFormat,
|
||||
sourceEditor,
|
||||
contextEditor,
|
||||
]);
|
||||
|
||||
const saveSourceRet = () => {
|
||||
if (saveSource)
|
||||
saveSource(scratch.source_code)
|
||||
}
|
||||
if (saveSource) saveSource(scratch.source_code);
|
||||
};
|
||||
|
||||
const saveContextRet = () => {
|
||||
if (saveContext)
|
||||
saveContext(scratch.context)
|
||||
}
|
||||
if (saveContext) saveContext(scratch.context);
|
||||
};
|
||||
|
||||
return [saveSourceRet, saveContextRet]
|
||||
return [saveSourceRet, saveContextRet];
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import * as api from "@/lib/api"
|
||||
import { useWarnBeforeUnload } from "@/lib/hooks"
|
||||
import * as api from "@/lib/api";
|
||||
import { useWarnBeforeUnload } from "@/lib/hooks";
|
||||
|
||||
export default function useWarnBeforeScratchUnload(scratch: api.Scratch) {
|
||||
const userIsYou = api.useUserIsYou()
|
||||
const isSaved = api.useIsScratchSaved(scratch)
|
||||
const userIsYou = api.useUserIsYou();
|
||||
const isSaved = api.useIsScratchSaved(scratch);
|
||||
|
||||
useWarnBeforeUnload(
|
||||
!isSaved,
|
||||
userIsYou(scratch.owner)
|
||||
? "You have not saved your changes to this scratch. Discard changes?"
|
||||
: "You have edited this scratch but not saved it in a fork. Discard changes?",
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import Scratch, { type Props as ScratchProps } from "./Scratch"
|
||||
import Scratch, { type Props as ScratchProps } from "./Scratch";
|
||||
|
||||
export type Props = ScratchProps
|
||||
export default Scratch
|
||||
export type Props = ScratchProps;
|
||||
export default Scratch;
|
||||
|
||||
@@ -1,80 +1,95 @@
|
||||
import Link from "next/link"
|
||||
import Link from "next/link";
|
||||
|
||||
import useSWR from "swr"
|
||||
import useSWR from "swr";
|
||||
|
||||
import LoadingSpinner from "@/components/loading.svg"
|
||||
import { PlatformIcon } from "@/components/PlatformSelect/PlatformIcon"
|
||||
import PlatformName from "@/components/PlatformSelect/PlatformName"
|
||||
import { getScoreText } from "@/components/ScoreBadge"
|
||||
import TimeAgo from "@/components/TimeAgo"
|
||||
import UserLink from "@/components/user/UserLink"
|
||||
import { type Scratch, type Preset, get, usePreset } from "@/lib/api"
|
||||
import { presetUrl, scratchUrl, scratchParentUrl } from "@/lib/api/urls"
|
||||
import LoadingSpinner from "@/components/loading.svg";
|
||||
import { PlatformIcon } from "@/components/PlatformSelect/PlatformIcon";
|
||||
import PlatformName from "@/components/PlatformSelect/PlatformName";
|
||||
import { getScoreText } from "@/components/ScoreBadge";
|
||||
import TimeAgo from "@/components/TimeAgo";
|
||||
import UserLink from "@/components/user/UserLink";
|
||||
import { type Scratch, type Preset, get, usePreset } from "@/lib/api";
|
||||
import { presetUrl, scratchUrl, scratchParentUrl } from "@/lib/api/urls";
|
||||
|
||||
import styles from "./AboutPanel.module.scss"
|
||||
import styles from "./AboutPanel.module.scss";
|
||||
|
||||
function ScratchLink({ url }: { url: string }) {
|
||||
const { data: scratch, error } = useSWR<Scratch>(url, get)
|
||||
const { data: scratch, error } = useSWR<Scratch>(url, get);
|
||||
|
||||
if (error) {
|
||||
throw error
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!scratch) {
|
||||
return <span className={styles.scratchLinkContainer}>
|
||||
<LoadingSpinner height={18} />
|
||||
</span>
|
||||
return (
|
||||
<span className={styles.scratchLinkContainer}>
|
||||
<LoadingSpinner height={18} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={styles.scratchLinkContainer}>
|
||||
<Link href={scratchUrl(scratch)} className={styles.scratchLink}>
|
||||
|
||||
{scratch.name || "Untitled scratch"}
|
||||
|
||||
</Link>
|
||||
{scratch.owner && <>
|
||||
<span className={styles.scratchLinkByText}>by</span>
|
||||
<UserLink user={scratch.owner} />
|
||||
</>}
|
||||
{scratch.owner && (
|
||||
<>
|
||||
<span className={styles.scratchLinkByText}>by</span>
|
||||
<UserLink user={scratch.owner} />
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export type Props = {
|
||||
scratch: Scratch
|
||||
setScratch?: (scratch: Partial<Scratch>) => void
|
||||
}
|
||||
scratch: Scratch;
|
||||
setScratch?: (scratch: Partial<Scratch>) => void;
|
||||
};
|
||||
|
||||
export default function AboutPanel({ scratch, setScratch }: Props) {
|
||||
const preset: Preset = usePreset(scratch.preset)
|
||||
const preset: Preset = usePreset(scratch.preset);
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div>
|
||||
<div className={styles.horizontalField}>
|
||||
<p className={styles.label}>Score</p>
|
||||
<span>{getScoreText(scratch.score, scratch.max_score, scratch.match_override)}</span>
|
||||
<span>
|
||||
{getScoreText(
|
||||
scratch.score,
|
||||
scratch.max_score,
|
||||
scratch.match_override,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{<div className={styles.horizontalField}>
|
||||
<p className={styles.label}>Owner</p>
|
||||
{scratch.owner && <UserLink user={scratch.owner} />}
|
||||
</div>}
|
||||
{scratch.parent &&<div className={styles.horizontalField}>
|
||||
<p className={styles.label}>Fork of</p>
|
||||
<ScratchLink url={scratchParentUrl(scratch)} />
|
||||
</div>}
|
||||
{
|
||||
<div className={styles.horizontalField}>
|
||||
<p className={styles.label}>Owner</p>
|
||||
{scratch.owner && <UserLink user={scratch.owner} />}
|
||||
</div>
|
||||
}
|
||||
{scratch.parent && (
|
||||
<div className={styles.horizontalField}>
|
||||
<p className={styles.label}>Fork of</p>
|
||||
<ScratchLink url={scratchParentUrl(scratch)} />
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.horizontalField}>
|
||||
<p className={styles.label}>Platform</p>
|
||||
<PlatformIcon platform={scratch.platform} className={styles.platformIcon} />
|
||||
<PlatformIcon
|
||||
platform={scratch.platform}
|
||||
className={styles.platformIcon}
|
||||
/>
|
||||
<PlatformName platform={scratch.platform} />
|
||||
</div>
|
||||
{preset && <div className={styles.horizontalField}>
|
||||
<p className={styles.label}>Preset</p>
|
||||
<Link href={presetUrl(preset)}>
|
||||
{preset.name}
|
||||
</Link>
|
||||
</div>}
|
||||
{preset && (
|
||||
<div className={styles.horizontalField}>
|
||||
<p className={styles.label}>Preset</p>
|
||||
<Link href={presetUrl(preset)}>{preset.name}</Link>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.horizontalField}>
|
||||
<p className={styles.label}>Created</p>
|
||||
<TimeAgo date={scratch.creation_time} />
|
||||
@@ -87,17 +102,23 @@ export default function AboutPanel({ scratch, setScratch }: Props) {
|
||||
|
||||
<hr className={styles.rule} />
|
||||
|
||||
{setScratch || scratch.description ? <div className={styles.grow}>
|
||||
<p className={styles.label}>Description</p>
|
||||
<textarea
|
||||
className={styles.textArea}
|
||||
value={scratch.description}
|
||||
disabled={!setScratch}
|
||||
onChange={event => setScratch?.({ description: event.target.value })}
|
||||
maxLength={5000}
|
||||
placeholder="Add any notes about the scratch here"
|
||||
/>
|
||||
</div> : <div />}
|
||||
{setScratch || scratch.description ? (
|
||||
<div className={styles.grow}>
|
||||
<p className={styles.label}>Description</p>
|
||||
<textarea
|
||||
className={styles.textArea}
|
||||
value={scratch.description}
|
||||
disabled={!setScratch}
|
||||
onChange={(event) =>
|
||||
setScratch?.({ description: event.target.value })
|
||||
}
|
||||
maxLength={5000}
|
||||
placeholder="Add any notes about the scratch here"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,61 +1,65 @@
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import type { EditorView } from "@codemirror/view"
|
||||
import { useDebounce } from "use-debounce"
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
import { useDebounce } from "use-debounce";
|
||||
|
||||
import CodeMirror from "@/components/Editor/CodeMirror"
|
||||
import Loading from "@/components/loading.svg"
|
||||
import * as api from "@/lib/api"
|
||||
import { scratchUrl } from "@/lib/api/urls"
|
||||
import { decompileSetup } from "@/lib/codemirror/basic-setup"
|
||||
import { cpp } from "@/lib/codemirror/cpp"
|
||||
import useCompareExtension from "@/lib/codemirror/useCompareExtension"
|
||||
import CodeMirror from "@/components/Editor/CodeMirror";
|
||||
import Loading from "@/components/loading.svg";
|
||||
import * as api from "@/lib/api";
|
||||
import { scratchUrl } from "@/lib/api/urls";
|
||||
import { decompileSetup } from "@/lib/codemirror/basic-setup";
|
||||
import { cpp } from "@/lib/codemirror/cpp";
|
||||
import useCompareExtension from "@/lib/codemirror/useCompareExtension";
|
||||
|
||||
import styles from "./DecompilePanel.module.scss"
|
||||
import styles from "./DecompilePanel.module.scss";
|
||||
|
||||
export type Props = {
|
||||
scratch: api.Scratch
|
||||
}
|
||||
scratch: api.Scratch;
|
||||
};
|
||||
|
||||
export default function DecompilePanel({ scratch }: Props) {
|
||||
const [decompiledCode, setDecompiledCode] = useState<string | null>(null)
|
||||
const viewRef = useRef<EditorView>()
|
||||
const compareExtension = useCompareExtension(viewRef, scratch.source_code)
|
||||
const [debouncedContext] = useDebounce(scratch.context, 1000, { leading: false, trailing: true })
|
||||
const [valueVersion, setValueVersion] = useState(0)
|
||||
const url = scratchUrl(scratch)
|
||||
const [decompiledCode, setDecompiledCode] = useState<string | null>(null);
|
||||
const viewRef = useRef<EditorView>();
|
||||
const compareExtension = useCompareExtension(viewRef, scratch.source_code);
|
||||
const [debouncedContext] = useDebounce(scratch.context, 1000, {
|
||||
leading: false,
|
||||
trailing: true,
|
||||
});
|
||||
const [valueVersion, setValueVersion] = useState(0);
|
||||
const url = scratchUrl(scratch);
|
||||
|
||||
useEffect(() => {
|
||||
api.post(`${url}/decompile`, {
|
||||
context: debouncedContext,
|
||||
compiler: scratch.compiler,
|
||||
}).then(({ decompilation }: { decompilation: string }) => {
|
||||
setDecompiledCode(decompilation)
|
||||
setValueVersion(v => v + 1)
|
||||
})
|
||||
}, [scratch.compiler, debouncedContext, url])
|
||||
setDecompiledCode(decompilation);
|
||||
setValueVersion((v) => v + 1);
|
||||
});
|
||||
}, [scratch.compiler, debouncedContext, url]);
|
||||
|
||||
const isLoading = decompiledCode === null || scratch.context !== debouncedContext
|
||||
const isLoading =
|
||||
decompiledCode === null || scratch.context !== debouncedContext;
|
||||
|
||||
return <div className={styles.container}>
|
||||
<section className={styles.main}>
|
||||
<p>
|
||||
Modify the context or compiler to see how the decompilation
|
||||
of the assembly changes.
|
||||
</p>
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<section className={styles.main}>
|
||||
<p>
|
||||
Modify the context or compiler to see how the decompilation
|
||||
of the assembly changes.
|
||||
</p>
|
||||
|
||||
{typeof decompiledCode === "string" && <CodeMirror
|
||||
className={styles.editor}
|
||||
value={decompiledCode}
|
||||
valueVersion={valueVersion}
|
||||
viewRef={viewRef}
|
||||
extensions={[
|
||||
decompileSetup,
|
||||
cpp(),
|
||||
compareExtension,
|
||||
]}
|
||||
/>}
|
||||
{isLoading && <Loading className={styles.loading} />}
|
||||
</section>
|
||||
</div>
|
||||
{typeof decompiledCode === "string" && (
|
||||
<CodeMirror
|
||||
className={styles.editor}
|
||||
value={decompiledCode}
|
||||
valueVersion={valueVersion}
|
||||
viewRef={viewRef}
|
||||
extensions={[decompileSetup, cpp(), compareExtension]}
|
||||
/>
|
||||
)}
|
||||
{isLoading && <Loading className={styles.loading} />}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,27 @@
|
||||
import dynamic from "next/dynamic"
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
import Loading from "@/components/loading.svg"
|
||||
import type { TerseScratch } from "@/lib/api/types"
|
||||
import Loading from "@/components/loading.svg";
|
||||
import type { TerseScratch } from "@/lib/api/types";
|
||||
|
||||
const SortableFamilyList = dynamic(() => import("@/components/Scratch/SortableFamilyList"), {
|
||||
loading: () => <div className="flex size-full items-center justify-center">
|
||||
<Loading className="size-8 animate-pulse" />
|
||||
</div>,
|
||||
})
|
||||
const SortableFamilyList = dynamic(
|
||||
() => import("@/components/Scratch/SortableFamilyList"),
|
||||
{
|
||||
loading: () => (
|
||||
<div className="flex size-full items-center justify-center">
|
||||
<Loading className="size-8 animate-pulse" />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
type Props = {
|
||||
scratch: TerseScratch
|
||||
}
|
||||
scratch: TerseScratch;
|
||||
};
|
||||
|
||||
export default function FamilyPanel({ scratch }: Props) {
|
||||
return <div className="h-full overflow-auto p-4">
|
||||
<SortableFamilyList scratch={scratch} />
|
||||
</div>
|
||||
return (
|
||||
<div className="h-full overflow-auto p-4">
|
||||
<SortableFamilyList scratch={scratch} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,240 +1,298 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import { type ReactNode, useState } from "react"
|
||||
import { type ReactNode, useState } from "react";
|
||||
|
||||
import Link from "next/link"
|
||||
import Link from "next/link";
|
||||
|
||||
import classNames from "classnames"
|
||||
import classNames from "classnames";
|
||||
|
||||
import TimeAgo from "@/components/TimeAgo"
|
||||
import * as api from "@/lib/api"
|
||||
import { presetUrl, scratchUrl } from "@/lib/api/urls"
|
||||
import getTranslation from "@/lib/i18n/translate"
|
||||
import TimeAgo from "@/components/TimeAgo";
|
||||
import * as api from "@/lib/api";
|
||||
import { presetUrl, scratchUrl } from "@/lib/api/urls";
|
||||
import getTranslation from "@/lib/i18n/translate";
|
||||
|
||||
import AsyncButton from "./AsyncButton"
|
||||
import Button from "./Button"
|
||||
import LoadingSpinner from "./loading.svg"
|
||||
import PlatformLink from "./PlatformLink"
|
||||
import { calculateScorePercent, percentToString } from "./ScoreBadge"
|
||||
import styles from "./ScratchList.module.scss"
|
||||
import Sort, { SortMode } from "./Sort"
|
||||
import UserLink from "./user/UserLink"
|
||||
import AsyncButton from "./AsyncButton";
|
||||
import Button from "./Button";
|
||||
import LoadingSpinner from "./loading.svg";
|
||||
import PlatformLink from "./PlatformLink";
|
||||
import { calculateScorePercent, percentToString } from "./ScoreBadge";
|
||||
import styles from "./ScratchList.module.scss";
|
||||
import Sort, { SortMode } from "./Sort";
|
||||
import UserLink from "./user/UserLink";
|
||||
|
||||
export interface Props {
|
||||
title?: string
|
||||
url?: string
|
||||
className?: string
|
||||
item?: ({ scratch }: { scratch: api.TerseScratch }) => JSX.Element
|
||||
emptyButtonLabel?: ReactNode
|
||||
isSortable?: boolean
|
||||
title?: string;
|
||||
url?: string;
|
||||
className?: string;
|
||||
item?: ({ scratch }: { scratch: api.TerseScratch }) => JSX.Element;
|
||||
emptyButtonLabel?: ReactNode;
|
||||
isSortable?: boolean;
|
||||
}
|
||||
|
||||
export default function ScratchList({ title, url, className, item, emptyButtonLabel, isSortable }: Props) {
|
||||
const [sortMode, setSortBy] = useState(SortMode.NEWEST_FIRST)
|
||||
const { results, isLoading, hasNext, loadNext } = api.usePaginated<api.TerseScratch>(`${url || "/scratch"}&ordering=${sortMode.toString()}`)
|
||||
export default function ScratchList({
|
||||
title,
|
||||
url,
|
||||
className,
|
||||
item,
|
||||
emptyButtonLabel,
|
||||
isSortable,
|
||||
}: Props) {
|
||||
const [sortMode, setSortBy] = useState(SortMode.NEWEST_FIRST);
|
||||
const { results, isLoading, hasNext, loadNext } =
|
||||
api.usePaginated<api.TerseScratch>(
|
||||
`${url || "/scratch"}&ordering=${sortMode.toString()}`,
|
||||
);
|
||||
|
||||
if (results.length === 0 && isLoading) {
|
||||
return <div className={classNames(styles.loading, className)}>
|
||||
<LoadingSpinner width="1.5em" height="1.5em" />
|
||||
Just a moment...
|
||||
</div>
|
||||
return (
|
||||
<div className={classNames(styles.loading, className)}>
|
||||
<LoadingSpinner width="1.5em" height="1.5em" />
|
||||
Just a moment...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const Item = item || ScratchItem
|
||||
const Item = item || ScratchItem;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex justify-between pb-2">
|
||||
<h2 className="font-medium text-lg tracking-tight">{title}</h2>
|
||||
{isSortable && <Sort sortMode={sortMode} setSortMode={setSortBy} />}
|
||||
{isSortable && (
|
||||
<Sort sortMode={sortMode} setSortMode={setSortBy} />
|
||||
)}
|
||||
</div>
|
||||
<ul className={classNames(styles.list, "rounded-md border-gray-6 text-sm", className)}>
|
||||
{results.map(scratch => (
|
||||
<ul
|
||||
className={classNames(
|
||||
styles.list,
|
||||
"rounded-md border-gray-6 text-sm",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{results.map((scratch) => (
|
||||
<Item key={scratchUrl(scratch)} scratch={scratch} />
|
||||
))}
|
||||
{results.length === 0 && emptyButtonLabel && <li className={styles.button}>
|
||||
<Link href="/new">
|
||||
|
||||
<Button>
|
||||
{emptyButtonLabel}
|
||||
</Button>
|
||||
|
||||
</Link>
|
||||
</li>}
|
||||
{hasNext && <li className={styles.button}>
|
||||
<AsyncButton onClick={loadNext}>
|
||||
Show more
|
||||
</AsyncButton>
|
||||
</li>}
|
||||
{results.length === 0 && emptyButtonLabel && (
|
||||
<li className={styles.button}>
|
||||
<Link href="/new">
|
||||
<Button>{emptyButtonLabel}</Button>
|
||||
</Link>
|
||||
</li>
|
||||
)}
|
||||
{hasNext && (
|
||||
<li className={styles.button}>
|
||||
<AsyncButton onClick={loadNext}>Show more</AsyncButton>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function getMatchPercentString(scratch: api.TerseScratch) {
|
||||
if (scratch.match_override) {
|
||||
return "100%"
|
||||
return "100%";
|
||||
}
|
||||
const matchPercent = calculateScorePercent(scratch.score, scratch.max_score)
|
||||
const matchPercentString = percentToString(matchPercent)
|
||||
const matchPercent = calculateScorePercent(
|
||||
scratch.score,
|
||||
scratch.max_score,
|
||||
);
|
||||
const matchPercentString = percentToString(matchPercent);
|
||||
|
||||
return matchPercentString
|
||||
return matchPercentString;
|
||||
}
|
||||
|
||||
export function ScratchItem({ scratch, children }: { scratch: api.TerseScratch, children?: ReactNode }) {
|
||||
const compilersTranslation = getTranslation("compilers")
|
||||
const compilerName = compilersTranslation.t(scratch.compiler)
|
||||
const matchPercentString = getMatchPercentString(scratch)
|
||||
const preset = api.usePreset(scratch.preset)
|
||||
const presetName = preset?.name
|
||||
export function ScratchItem({
|
||||
scratch,
|
||||
children,
|
||||
}: { scratch: api.TerseScratch; children?: ReactNode }) {
|
||||
const compilersTranslation = getTranslation("compilers");
|
||||
const compilerName = compilersTranslation.t(scratch.compiler);
|
||||
const matchPercentString = getMatchPercentString(scratch);
|
||||
const preset = api.usePreset(scratch.preset);
|
||||
const presetName = preset?.name;
|
||||
|
||||
const presetOrCompiler = presetName ?
|
||||
const presetOrCompiler = presetName ? (
|
||||
<Link href={presetUrl(preset)} className={styles.link}>
|
||||
{presetName}
|
||||
</Link> : <span>{compilerName}</span>
|
||||
</Link>
|
||||
) : (
|
||||
<span>{compilerName}</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<li className={styles.item}>
|
||||
<div className={styles.scratch}>
|
||||
<div className={styles.header}>
|
||||
<PlatformLink size={16} scratch={scratch} className={styles.icon} />
|
||||
<Link href={scratchUrl(scratch)} className={classNames(styles.link, styles.name)}>
|
||||
<PlatformLink
|
||||
size={16}
|
||||
scratch={scratch}
|
||||
className={styles.icon}
|
||||
/>
|
||||
<Link
|
||||
href={scratchUrl(scratch)}
|
||||
className={classNames(styles.link, styles.name)}
|
||||
>
|
||||
{scratch.name}
|
||||
</Link>
|
||||
<div className={styles.owner}>
|
||||
{scratch.owner ?
|
||||
{scratch.owner ? (
|
||||
<UserLink user={scratch.owner} />
|
||||
:
|
||||
) : (
|
||||
<div>No Owner</div>
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.metadata}>
|
||||
<span>
|
||||
{presetOrCompiler} • {matchPercentString} matched • <TimeAgo date={scratch.last_updated} />
|
||||
{presetOrCompiler} • {matchPercentString} matched •{" "}
|
||||
<TimeAgo date={scratch.last_updated} />
|
||||
</span>
|
||||
<div className={styles.actions}>
|
||||
{children}
|
||||
</div>
|
||||
<div className={styles.actions}>{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function ScratchItemNoOwner({ scratch }: { scratch: api.TerseScratch }) {
|
||||
const compilersTranslation = getTranslation("compilers")
|
||||
const compilerName = compilersTranslation.t(scratch.compiler)
|
||||
const matchPercentString = getMatchPercentString(scratch)
|
||||
const preset = api.usePreset(scratch.preset)
|
||||
const presetName = preset?.name
|
||||
const compilersTranslation = getTranslation("compilers");
|
||||
const compilerName = compilersTranslation.t(scratch.compiler);
|
||||
const matchPercentString = getMatchPercentString(scratch);
|
||||
const preset = api.usePreset(scratch.preset);
|
||||
const presetName = preset?.name;
|
||||
|
||||
const presetOrCompiler = presetName ?
|
||||
const presetOrCompiler = presetName ? (
|
||||
<Link href={presetUrl(preset)} className={styles.link}>
|
||||
{presetName}
|
||||
</Link> : <span>{compilerName}</span>
|
||||
</Link>
|
||||
) : (
|
||||
<span>{compilerName}</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<li className={styles.item}>
|
||||
<div className={styles.scratch}>
|
||||
<div className={styles.header}>
|
||||
<PlatformLink size={16} scratch={scratch} className={styles.icon} />
|
||||
<Link href={scratchUrl(scratch)} className={classNames(styles.link, styles.name)}>
|
||||
<PlatformLink
|
||||
size={16}
|
||||
scratch={scratch}
|
||||
className={styles.icon}
|
||||
/>
|
||||
<Link
|
||||
href={scratchUrl(scratch)}
|
||||
className={classNames(styles.link, styles.name)}
|
||||
>
|
||||
{scratch.name}
|
||||
</Link>
|
||||
<div>
|
||||
{/* empty div for alignment */}
|
||||
</div>
|
||||
<div>{/* empty div for alignment */}</div>
|
||||
</div>
|
||||
<div className={styles.metadata}>
|
||||
<span>
|
||||
{presetOrCompiler} • {matchPercentString} matched • <TimeAgo date={scratch.last_updated} />
|
||||
{presetOrCompiler} • {matchPercentString} matched •{" "}
|
||||
<TimeAgo date={scratch.last_updated} />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function ScratchItemPlatformList({ scratch }: { scratch: api.TerseScratch }) {
|
||||
const compilersTranslation = getTranslation("compilers")
|
||||
const compilerName = compilersTranslation.t(scratch.compiler)
|
||||
const matchPercentString = getMatchPercentString(scratch)
|
||||
const preset = api.usePreset(scratch.preset)
|
||||
const presetName = preset?.name
|
||||
export function ScratchItemPlatformList({
|
||||
scratch,
|
||||
}: { scratch: api.TerseScratch }) {
|
||||
const compilersTranslation = getTranslation("compilers");
|
||||
const compilerName = compilersTranslation.t(scratch.compiler);
|
||||
const matchPercentString = getMatchPercentString(scratch);
|
||||
const preset = api.usePreset(scratch.preset);
|
||||
const presetName = preset?.name;
|
||||
|
||||
const presetOrCompiler = presetName ?
|
||||
const presetOrCompiler = presetName ? (
|
||||
<Link href={presetUrl(preset)} className={styles.link}>
|
||||
{presetName}
|
||||
</Link> : <span>{compilerName}</span>
|
||||
</Link>
|
||||
) : (
|
||||
<span>{compilerName}</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<li className={styles.item}>
|
||||
<div className={styles.scratch}>
|
||||
<div className={styles.header}>
|
||||
<Link href={scratchUrl(scratch)} className={classNames(styles.link, styles.name)}>
|
||||
<Link
|
||||
href={scratchUrl(scratch)}
|
||||
className={classNames(styles.link, styles.name)}
|
||||
>
|
||||
{scratch.name}
|
||||
</Link>
|
||||
<div className={styles.owner}>
|
||||
{scratch.owner ?
|
||||
{scratch.owner ? (
|
||||
<UserLink user={scratch.owner} />
|
||||
:
|
||||
) : (
|
||||
<div>No Owner</div>
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div className={styles.metadata}>
|
||||
<span>
|
||||
{presetOrCompiler} • {matchPercentString} matched • <TimeAgo date={scratch.last_updated} />
|
||||
{presetOrCompiler} • {matchPercentString} matched •{" "}
|
||||
<TimeAgo date={scratch.last_updated} />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function ScratchItemPresetList({ scratch }: { scratch: api.TerseScratch }) {
|
||||
const matchPercentString = getMatchPercentString(scratch)
|
||||
export function ScratchItemPresetList({
|
||||
scratch,
|
||||
}: { scratch: api.TerseScratch }) {
|
||||
const matchPercentString = getMatchPercentString(scratch);
|
||||
|
||||
return (
|
||||
<li className={styles.item}>
|
||||
<div className={styles.scratch}>
|
||||
<div className={styles.header}>
|
||||
<Link href={scratchUrl(scratch)} className={classNames(styles.link, styles.name)}>
|
||||
<Link
|
||||
href={scratchUrl(scratch)}
|
||||
className={classNames(styles.link, styles.name)}
|
||||
>
|
||||
{scratch.name}
|
||||
</Link>
|
||||
<div className={styles.metadata}>
|
||||
<span>
|
||||
{matchPercentString} matched • <TimeAgo date={scratch.last_updated} />
|
||||
{matchPercentString} matched •{" "}
|
||||
<TimeAgo date={scratch.last_updated} />
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.owner}>
|
||||
{scratch.owner ?
|
||||
{scratch.owner ? (
|
||||
<UserLink user={scratch.owner} />
|
||||
:
|
||||
) : (
|
||||
<div>No Owner</div>
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function SingleLineScratchItem({ scratch }: { scratch: api.TerseScratch }) {
|
||||
const matchPercentString = getMatchPercentString(scratch)
|
||||
export function SingleLineScratchItem({
|
||||
scratch,
|
||||
}: { scratch: api.TerseScratch }) {
|
||||
const matchPercentString = getMatchPercentString(scratch);
|
||||
|
||||
return (
|
||||
<li className={styles.singleLine}>
|
||||
<PlatformLink size={16} scratch={scratch} className={styles.icon} />
|
||||
<Link href={scratchUrl(scratch)} className={classNames(styles.link, styles.name)}>
|
||||
<Link
|
||||
href={scratchUrl(scratch)}
|
||||
className={classNames(styles.link, styles.name)}
|
||||
>
|
||||
{scratch.name}
|
||||
</Link>
|
||||
<div className={styles.metadata}>
|
||||
{matchPercentString}
|
||||
</div>
|
||||
<div className={styles.metadata}>{matchPercentString}</div>
|
||||
</li>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
import { createContext } from "react"
|
||||
import { createContext } from "react";
|
||||
|
||||
export const ScrollContext = createContext(null)
|
||||
export const ScrollContext = createContext(null);
|
||||
|
||||
@@ -1,24 +1,31 @@
|
||||
import type { ReactNode, ChangeEventHandler } from "react"
|
||||
import type { ReactNode, ChangeEventHandler } from "react";
|
||||
|
||||
import { ChevronDownIcon } from "@primer/octicons-react"
|
||||
import { ChevronDownIcon } from "@primer/octicons-react";
|
||||
|
||||
import styles from "./Select.module.scss"
|
||||
import styles from "./Select.module.scss";
|
||||
|
||||
export type Props = {
|
||||
className?: string
|
||||
onChange: ChangeEventHandler<HTMLSelectElement>
|
||||
children: ReactNode
|
||||
value?: string
|
||||
}
|
||||
className?: string;
|
||||
onChange: ChangeEventHandler<HTMLSelectElement>;
|
||||
children: ReactNode;
|
||||
value?: string;
|
||||
};
|
||||
|
||||
export default function Select({ onChange, children, className, value }: Props) {
|
||||
return <div className={`${styles.group} ${className}`}>
|
||||
<select onChange={onChange} value={value}>
|
||||
{children}
|
||||
</select>
|
||||
export default function Select({
|
||||
onChange,
|
||||
children,
|
||||
className,
|
||||
value,
|
||||
}: Props) {
|
||||
return (
|
||||
<div className={`${styles.group} ${className}`}>
|
||||
<select onChange={onChange} value={value}>
|
||||
{children}
|
||||
</select>
|
||||
|
||||
<div className={styles.icon}>
|
||||
<ChevronDownIcon size={16} />
|
||||
<div className={styles.icon}>
|
||||
<ChevronDownIcon size={16} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,37 +1,39 @@
|
||||
import { useEffect } from "react"
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { ChevronDownIcon } from "@primer/octicons-react"
|
||||
import { ChevronDownIcon } from "@primer/octicons-react";
|
||||
|
||||
import styles from "./Select.module.scss"
|
||||
import styles from "./Select.module.scss";
|
||||
|
||||
export type Props = {
|
||||
options: { [key: string]: string }
|
||||
value: string
|
||||
className?: string
|
||||
onChange: (value: string) => void
|
||||
}
|
||||
options: { [key: string]: string };
|
||||
value: string;
|
||||
className?: string;
|
||||
onChange: (value: string) => void;
|
||||
};
|
||||
|
||||
export default function Select({ options, value, onChange, className }: Props) {
|
||||
|
||||
useEffect(() => {
|
||||
if (!value)
|
||||
onChange(Object.keys(options)[0])
|
||||
}, [value, options, onChange])
|
||||
if (!value) onChange(Object.keys(options)[0]);
|
||||
}, [value, options, onChange]);
|
||||
|
||||
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>
|
||||
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 className={styles.icon}>
|
||||
<ChevronDownIcon size={16} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
// Next.js currently doesn't support head.js files accessing search params:
|
||||
// https://github.com/vercel/next.js/pull/43305
|
||||
// So we'll update it on the client instead, with this component.
|
||||
// In future - once this is supported - we can use dynamic <AppHead> instead.
|
||||
|
||||
import { usePageTitle } from "@/lib/hooks"
|
||||
import { usePageTitle } from "@/lib/hooks";
|
||||
|
||||
export default function SetPageTitle({ title }: { title: string }) {
|
||||
usePageTitle(title)
|
||||
return <></>
|
||||
usePageTitle(title);
|
||||
return <></>;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user