Run Biome formatter (#1392)

* Run formatter

* Part 2
This commit is contained in:
Ethan Roseman
2024-12-11 01:03:56 +09:00
committed by GitHub
parent a003f5b462
commit 4ef64cbebc
140 changed files with 8223 additions and 6157 deletions

View File

@@ -48,10 +48,5 @@
"useSortedClasses": "error"
}
}
},
"javascript": {
"formatter": {
"enabled": false
}
}
}

View File

@@ -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;

View File

@@ -1,7 +1,3 @@
module.exports = {
plugins: [
"tailwindcss",
"autoprefixer",
"cssnano",
],
}
plugins: ["tailwindcss", "autoprefixer", "cssnano"],
};

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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;

View File

@@ -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>
);
}

View File

@@ -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>
</>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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;

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
)
);
}

View File

@@ -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>
)
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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 />
</>
);
}

View File

@@ -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>
</>
);
}

View File

@@ -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>
);
}

View File

@@ -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 />
</>
);
}

View File

@@ -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>
</>
);
}

View File

@@ -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 />
</>
);
}

View File

@@ -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>
);
}

View File

@@ -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} />;
}

View File

@@ -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 <></>;
}

View File

@@ -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",
}
};

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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() {
},
],
},
)
);
}

View File

@@ -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>
</>
);
}

View File

@@ -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 />;
}

View File

@@ -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,
}
};
}

View File

@@ -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>
);
}

View File

@@ -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,
},
)
);
}

View File

@@ -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}
/>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
)
);
}

View File

@@ -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;

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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);

View File

@@ -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 };
},
);

View File

@@ -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)}
/>
);
}

View File

@@ -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]);
}

View File

@@ -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>
);
}

View File

@@ -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
}
/>
);
}

View File

@@ -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;
}
}

View File

@@ -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>
</>
);
}

View File

@@ -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>;
}

View File

@@ -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>
);
}
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
)
);
}

View File

@@ -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} />;
}

View File

@@ -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>
</>
);
}

View File

@@ -1,3 +1,3 @@
import Nav from "./Nav"
import Nav from "./Nav";
export default Nav
export default Nav;

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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} />;
}
}

View File

@@ -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>
</>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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;

View File

@@ -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>
)
);
}

View File

@@ -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>
);
}
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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, "&lt;").replace(/>/g, "&gt;")
return html.replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
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>
)}
</>
);
}

View File

@@ -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>
);
}

View File

@@ -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]),
]
];
}

View File

@@ -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];
}

View File

@@ -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?",
)
);
}

View File

@@ -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;

View File

@@ -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>
)
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
)
);
}

View File

@@ -1,3 +1,3 @@
import { createContext } from "react"
import { createContext } from "react";
export const ScrollContext = createContext(null)
export const ScrollContext = createContext(null);

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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