You've already forked decomp.dev
mirror of
https://github.com/encounter/decomp.dev.git
synced 2026-07-10 03:18:48 -07:00
707 lines
20 KiB
TypeScript
707 lines
20 KiB
TypeScript
import hljs from 'highlight.js/lib/core';
|
|
import { StrictMode, useEffect, useMemo, useRef, useState } from 'react';
|
|
import { createRoot } from 'react-dom/client';
|
|
import 'highlight.js/styles/hybrid.css';
|
|
import styles from './api.module.css';
|
|
|
|
hljs.registerLanguage('json', require('highlight.js/lib/languages/json'));
|
|
hljs.registerLanguage(
|
|
'plaintext',
|
|
require('highlight.js/lib/languages/plaintext'),
|
|
);
|
|
|
|
const CodeBlock = ({
|
|
value,
|
|
language,
|
|
originalValue,
|
|
}: { value: string; language?: string | null; originalValue?: string }) => {
|
|
const codeRef = useRef<HTMLPreElement>(null);
|
|
const langClass = language ? `language-${language}` : undefined;
|
|
|
|
// biome-ignore lint/correctness/useExhaustiveDependencies:
|
|
useEffect(() => {
|
|
if (codeRef.current && !codeRef.current.dataset.highlighted) {
|
|
hljs.highlightElement(codeRef.current);
|
|
}
|
|
}, [value]);
|
|
|
|
const copyToClipboard = () => {
|
|
navigator.clipboard.writeText(originalValue || value).catch((e) => {
|
|
console.error('Failed to copy text', e);
|
|
});
|
|
};
|
|
|
|
return (
|
|
<pre className={styles.codeBlock}>
|
|
<button
|
|
className={`secondary ${styles.copyButton} icon-copy`}
|
|
onClick={copyToClipboard}
|
|
/>
|
|
<code ref={codeRef} className={langClass}>
|
|
{value}
|
|
</code>
|
|
</pre>
|
|
);
|
|
};
|
|
|
|
type ProjectsResponse = {
|
|
projects: ProjectResponse[];
|
|
};
|
|
|
|
type ProjectResponse = {
|
|
id: string;
|
|
owner: string;
|
|
repo: string;
|
|
name: string | null;
|
|
short_name: string | null;
|
|
platform: string | null;
|
|
default_version: string;
|
|
report_versions: string[];
|
|
report_categories: CategoryResponse[];
|
|
};
|
|
|
|
type CategoryResponse = {
|
|
id: string;
|
|
name: string;
|
|
};
|
|
|
|
const fetchProjects = async () => {
|
|
const url = new URL('/projects', window.location.origin);
|
|
url.searchParams.append('sort', 'name');
|
|
const response = await fetch(url, {
|
|
headers: { Accept: 'application/json' },
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error('Failed to fetch projects');
|
|
}
|
|
return (await response.json()) as ProjectsResponse;
|
|
};
|
|
|
|
const fetchProjectById = async (id: string, version: string | null) => {
|
|
const url = new URL(`/projects/${id}`, window.location.origin);
|
|
if (version) {
|
|
url.searchParams.append('version', version);
|
|
}
|
|
const response = await fetch(url, {
|
|
headers: { Accept: 'application/json' },
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error('Failed to fetch projects');
|
|
}
|
|
return (await response.json()) as ProjectResponse;
|
|
};
|
|
|
|
const fetchApiUrl = async (url: string) => {
|
|
const response = await fetch(url, {
|
|
headers: { Accept: 'application/json' },
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error('Failed to fetch projects');
|
|
}
|
|
return await response.text();
|
|
};
|
|
|
|
const ProjectForm = () => {
|
|
const [projects, setProjects] = useState<ProjectResponse[]>([]);
|
|
const [loadingProjects, setLoadingProjects] = useState(true);
|
|
const [loadingProject, setLoadingProject] = useState(true);
|
|
|
|
type Selected = {
|
|
project: string | null;
|
|
version: string | null;
|
|
category: string | null;
|
|
subcategory: string | null;
|
|
};
|
|
const [selected, setSelected] = useState<Selected>({
|
|
project: null,
|
|
version: null,
|
|
category: null,
|
|
subcategory: null,
|
|
});
|
|
const [mode, setMode] = useState('overview');
|
|
const [format, setFormat] = useState('json');
|
|
const [formatOptions, setFormatOptions] = useState<any>({});
|
|
const [useProjectId, setUseProjectId] = useState(false);
|
|
|
|
const [currentProject, setCurrentProject] = useState<ProjectResponse | null>(
|
|
null,
|
|
);
|
|
|
|
const categories = useMemo(() => {
|
|
const result: Record<
|
|
string,
|
|
{ name: string; subcategories: CategoryResponse[] }
|
|
> = {};
|
|
currentProject?.report_categories.forEach((c) => {
|
|
const [top, sub] = c.id.split('.', 2);
|
|
if (!result[top]) {
|
|
result[top] = { name: c.id === top ? c.name : top, subcategories: [] };
|
|
}
|
|
if (sub) {
|
|
result[top].subcategories.push({ id: sub, name: c.name });
|
|
} else {
|
|
result[top].name = c.name;
|
|
}
|
|
});
|
|
return result;
|
|
}, [currentProject]);
|
|
|
|
useEffect(() => {
|
|
setLoadingProjects(true);
|
|
fetchProjects()
|
|
.then((response) => {
|
|
setProjects(response.projects);
|
|
const url = new URL(document.location.href);
|
|
const deferredProject = url.searchParams.get('project');
|
|
if (deferredProject) {
|
|
const project = response.projects.find(
|
|
(p) => p.id.toString() === deferredProject,
|
|
);
|
|
if (project) {
|
|
setCurrentProject(project);
|
|
setSelected({
|
|
project: project.id,
|
|
version: null,
|
|
category: null,
|
|
subcategory: null,
|
|
});
|
|
}
|
|
}
|
|
setLoadingProjects(false);
|
|
})
|
|
.catch((error) => {
|
|
console.error('Error fetching projects:', error);
|
|
setProjects([]);
|
|
setLoadingProjects(false);
|
|
});
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (selected.project === null) {
|
|
return;
|
|
}
|
|
setLoadingProject(true);
|
|
fetchProjectById(selected.project, selected.version)
|
|
.then((response) => {
|
|
setCurrentProject(response);
|
|
setLoadingProject(false);
|
|
})
|
|
.catch((error) => {
|
|
console.error('Error fetching project by ID:', error);
|
|
setCurrentProject(null);
|
|
setLoadingProject(false);
|
|
});
|
|
}, [selected.project, selected.version]);
|
|
|
|
const url = new URL('/', window.location.origin);
|
|
url.searchParams.append('mode', mode);
|
|
if (currentProject) {
|
|
if (useProjectId) {
|
|
url.pathname = `/projects/${currentProject.id}`;
|
|
if (selected.version) {
|
|
url.searchParams.append('version', selected.version);
|
|
}
|
|
} else {
|
|
url.pathname = `/${currentProject.owner}/${currentProject.repo}`;
|
|
if (selected.version) {
|
|
url.pathname += `/${selected.version || currentProject.default_version}`;
|
|
}
|
|
}
|
|
if (selected.category) {
|
|
const cat = selected.subcategory
|
|
? `${selected.category}.${selected.subcategory}`
|
|
: selected.category;
|
|
url.searchParams.append('category', cat);
|
|
}
|
|
}
|
|
url.pathname += `.${format}`;
|
|
if (mode === 'shield') {
|
|
if (formatOptions.label) {
|
|
url.searchParams.append('label', formatOptions.label);
|
|
}
|
|
if (formatOptions.labelColor) {
|
|
url.searchParams.append('labelColor', formatOptions.labelColor);
|
|
}
|
|
if (formatOptions.color) {
|
|
url.searchParams.append('color', formatOptions.color);
|
|
}
|
|
if (formatOptions.style) {
|
|
url.searchParams.append('style', formatOptions.style);
|
|
}
|
|
if (formatOptions.measure) {
|
|
url.searchParams.append('measure', formatOptions.measure);
|
|
}
|
|
}
|
|
|
|
const updateMode = (newMode: string) => {
|
|
setMode(newMode);
|
|
setFormat(formatsByMode[newMode][0]);
|
|
setFormatOptions({});
|
|
};
|
|
|
|
const updateFormat = (newFormat: string) => {
|
|
setFormat(newFormat);
|
|
};
|
|
|
|
let modeOptions = null;
|
|
if (mode === 'shield') {
|
|
modeOptions = (
|
|
<>
|
|
<div className="grid">
|
|
<label>
|
|
Measure
|
|
<select
|
|
name="measure"
|
|
value={formatOptions.measure || ''}
|
|
onChange={(e) =>
|
|
setFormatOptions((existing: any) => ({
|
|
...existing,
|
|
measure: e.target.value,
|
|
}))
|
|
}
|
|
>
|
|
<option value="">Default</option>
|
|
<option value="fuzzy_match_percent">Fuzzy Match (Percent)</option>
|
|
<option value="matched_code_percent">
|
|
Matched Code (Percent)
|
|
</option>
|
|
<option value="matched_code_bytes">Matched Code (Bytes)</option>
|
|
<option value="matched_code_size">Matched Code (Size)</option>
|
|
<option value="matched_data_percent">
|
|
Matched Data (Percent)
|
|
</option>
|
|
<option value="matched_data_bytes">Matched Data (Bytes)</option>
|
|
<option value="matched_data_size">Matched Data (Size)</option>
|
|
<option value="matched_functions">
|
|
Matched Functions (Count)
|
|
</option>
|
|
<option value="matched_functions_percent">
|
|
Matched Functions (Percent)
|
|
</option>
|
|
<option value="complete_code_percent">
|
|
Linked Code (Percent)
|
|
</option>
|
|
<option value="complete_code_bytes">Linked Code (Bytes)</option>
|
|
<option value="complete_code_size">Linked Code (Size)</option>
|
|
<option value="complete_data_percent">
|
|
Linked Data (Percent)
|
|
</option>
|
|
<option value="complete_data_bytes">Linked Data (Bytes)</option>
|
|
<option value="complete_data_size">Linked Data (Size)</option>
|
|
<option value="complete_units">Linked Units (Count)</option>
|
|
<option value="complete_units_percent">
|
|
Linked Units (Percent)
|
|
</option>
|
|
</select>
|
|
</label>
|
|
</div>
|
|
<div className="grid">
|
|
<label>
|
|
Style
|
|
<select
|
|
name="style"
|
|
value={formatOptions.style || ''}
|
|
onChange={(e) =>
|
|
setFormatOptions((existing: any) => ({
|
|
...existing,
|
|
style: e.target.value,
|
|
}))
|
|
}
|
|
>
|
|
<option value="">Default</option>
|
|
<option value="flat">Flat</option>
|
|
<option value="plastic">Plastic</option>
|
|
<option value="flatsquare">Flat Square</option>
|
|
</select>
|
|
</label>
|
|
<label>
|
|
Color
|
|
<div className={styles.clearContainer}>
|
|
<input
|
|
name="color"
|
|
type="color"
|
|
value={formatOptions.color || ''}
|
|
onChange={(e) =>
|
|
setFormatOptions((existing: any) => ({
|
|
...existing,
|
|
color: e.target.value,
|
|
}))
|
|
}
|
|
/>
|
|
<button
|
|
className="secondary icon-cancel"
|
|
onClick={() =>
|
|
setFormatOptions((existing: any) => ({
|
|
...existing,
|
|
color: null,
|
|
}))
|
|
}
|
|
/>
|
|
</div>
|
|
</label>
|
|
</div>
|
|
<div className="grid">
|
|
<label>
|
|
Label
|
|
<input
|
|
name="label"
|
|
type="text"
|
|
value={formatOptions.label || ''}
|
|
onChange={(e) =>
|
|
setFormatOptions((existing: any) => ({
|
|
...existing,
|
|
label: e.target.value,
|
|
}))
|
|
}
|
|
/>
|
|
</label>
|
|
<label>
|
|
Label Color
|
|
<div className={styles.clearContainer}>
|
|
<input
|
|
name="labelColor"
|
|
type="color"
|
|
value={formatOptions.labelColor || ''}
|
|
onChange={(e) =>
|
|
setFormatOptions((existing: any) => ({
|
|
...existing,
|
|
labelColor: e.target.value,
|
|
}))
|
|
}
|
|
/>
|
|
<button
|
|
className="secondary icon-cancel"
|
|
onClick={() =>
|
|
setFormatOptions((existing: any) => ({
|
|
...existing,
|
|
labelColor: null,
|
|
}))
|
|
}
|
|
/>
|
|
</div>
|
|
</label>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
<div className="grid">
|
|
<label>
|
|
Project
|
|
<select
|
|
name="project"
|
|
value={selected.project || ''}
|
|
disabled={loadingProjects}
|
|
onChange={(e) =>
|
|
setSelected({
|
|
project: e.target.value,
|
|
version: null,
|
|
category: null,
|
|
subcategory: null,
|
|
})
|
|
}
|
|
>
|
|
<option value="">Select a project</option>
|
|
{projects.map((project) => (
|
|
<option key={project.id} value={project.id}>
|
|
{project.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<label>
|
|
Version
|
|
<select
|
|
name="version"
|
|
value={selected.version || ''}
|
|
disabled={loadingProject || !currentProject}
|
|
onChange={(e) =>
|
|
setSelected((existing) => ({
|
|
project: existing.project,
|
|
version: e.target.value,
|
|
category: null,
|
|
subcategory: null,
|
|
}))
|
|
}
|
|
>
|
|
<option value="">Default</option>
|
|
{currentProject?.report_versions.map((version) => (
|
|
<option key={version} value={version}>
|
|
{version}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<label>
|
|
Category
|
|
<select
|
|
name="category"
|
|
value={selected.category || ''}
|
|
disabled={loadingProject || !currentProject}
|
|
onChange={(e) =>
|
|
setSelected((existing) => ({
|
|
project: existing.project,
|
|
version: existing.version,
|
|
category: e.target.value || null,
|
|
subcategory: null,
|
|
}))
|
|
}
|
|
>
|
|
<option value="">Default</option>
|
|
{Object.entries(categories).map(([id, info]) => (
|
|
<option key={id} value={id}>
|
|
{info.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
</div>
|
|
{selected.category &&
|
|
categories[selected.category]?.subcategories.length > 0 && (
|
|
<div className="grid">
|
|
<label>
|
|
Subcategory
|
|
<select
|
|
name="subcategory"
|
|
value={selected.subcategory || ''}
|
|
disabled={loadingProject || !currentProject}
|
|
onChange={(e) =>
|
|
setSelected((existing) => ({
|
|
...existing,
|
|
subcategory: e.target.value || null,
|
|
}))
|
|
}
|
|
>
|
|
<option value="">Default</option>
|
|
{categories[selected.category].subcategories.map((sub) => (
|
|
<option key={sub.id} value={sub.id}>
|
|
{sub.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
</div>
|
|
)}
|
|
<div className="grid">
|
|
<label>
|
|
Mode
|
|
<select
|
|
name="mode"
|
|
value={mode}
|
|
onChange={(e) => updateMode(e.target.value)}
|
|
>
|
|
<option value="overview">Overview (Project info & measures)</option>
|
|
<option value="measures">Measures (Simple report data)</option>
|
|
<option value="report">
|
|
Report (Full report data, very large!)
|
|
</option>
|
|
<option value="shield">Shield (Progress badge)</option>
|
|
<option value="history">History (Historical report data)</option>
|
|
</select>
|
|
</label>
|
|
<label>
|
|
Format
|
|
<select
|
|
name="format"
|
|
value={format}
|
|
onChange={(e) => updateFormat(e.target.value)}
|
|
>
|
|
{formatsByMode[mode].map((format) => {
|
|
switch (format) {
|
|
case 'json':
|
|
return (
|
|
<option key={format} value={format}>
|
|
JSON
|
|
</option>
|
|
);
|
|
case 'binpb':
|
|
return (
|
|
<option key={format} value={format}>
|
|
Protobuf
|
|
</option>
|
|
);
|
|
case 'svg':
|
|
return (
|
|
<option key={format} value={format}>
|
|
SVG
|
|
</option>
|
|
);
|
|
case 'png':
|
|
return (
|
|
<option key={format} value={format}>
|
|
PNG
|
|
</option>
|
|
);
|
|
}
|
|
})}
|
|
</select>
|
|
</label>
|
|
</div>
|
|
<div className="grid">
|
|
<label>
|
|
<input
|
|
type="checkbox"
|
|
name="useProjectId"
|
|
checked={useProjectId}
|
|
onChange={(e) => setUseProjectId(e.target.checked)}
|
|
/>
|
|
Use project ID instead of repository name
|
|
</label>
|
|
</div>
|
|
<hr />
|
|
{modeOptions}
|
|
{currentProject && <ApiPreview url={url.toString()} format={format} />}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const formatsByMode: Record<string, string[]> = {
|
|
overview: ['json', 'svg', 'png'],
|
|
measures: ['json', 'binpb'],
|
|
report: ['json', 'binpb'],
|
|
shield: ['svg', 'png', 'json'],
|
|
history: ['json'],
|
|
};
|
|
|
|
const prettifyJson = (data: string): string => {
|
|
let parsed: any;
|
|
try {
|
|
parsed = JSON.parse(data);
|
|
} catch (error) {
|
|
// Return the original data if parsing fails
|
|
return data;
|
|
}
|
|
return JSON.stringify(parsed, null, 2);
|
|
};
|
|
|
|
const ApiPreview = ({
|
|
url,
|
|
format,
|
|
}: { url: string | null; format: string }) => {
|
|
type ResponseData = {
|
|
data: string;
|
|
format: string;
|
|
};
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [response, setResponse] = useState<ResponseData | null>(null);
|
|
|
|
// biome-ignore lint/correctness/useExhaustiveDependencies:
|
|
useEffect(() => {
|
|
setResponse(null);
|
|
}, [url]);
|
|
|
|
const fetchData = async () => {
|
|
if (!url) {
|
|
return;
|
|
}
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
const response = await fetchApiUrl(url);
|
|
setResponse({ data: response, format });
|
|
} catch (error) {
|
|
console.error('Error fetching API URL:', error);
|
|
setError('Failed to fetch data');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const stringified = useMemo(() => {
|
|
if (!response) {
|
|
return '';
|
|
}
|
|
const maxLength = 250000; // 250 KB
|
|
let stringified: string;
|
|
if (response.format === 'json') {
|
|
stringified = prettifyJson(response.data);
|
|
} else if (response.format === 'binpb') {
|
|
stringified = `// Binary protobuf size ${formatBytes(response.data.length)}`;
|
|
} else {
|
|
stringified = response.data;
|
|
}
|
|
if (stringified.length > maxLength) {
|
|
return `// Response was ${formatBytes(response.data.length)}, preview truncated to ${formatBytes(maxLength, 0)}\n${stringified.slice(0, maxLength)}`;
|
|
}
|
|
return stringified;
|
|
}, [response]);
|
|
|
|
if (!url) {
|
|
return null;
|
|
}
|
|
|
|
let preview = null;
|
|
if (response) {
|
|
if (response.format === 'json' || response.format === 'binpb') {
|
|
preview = (
|
|
<CodeBlock
|
|
value={stringified}
|
|
originalValue={response.data}
|
|
language="json"
|
|
/>
|
|
);
|
|
}
|
|
}
|
|
if (error) {
|
|
preview = <CodeBlock value={error} language="plaintext" />;
|
|
}
|
|
|
|
if (format === 'svg' || format === 'png') {
|
|
preview = <img src={url} alt="Preview" />;
|
|
} else if (format === 'json' || format === 'binpb') {
|
|
preview = (
|
|
<>
|
|
<div className="grid">
|
|
<button
|
|
className="secondary"
|
|
onClick={fetchData}
|
|
disabled={loading || (format !== 'json' && format !== 'binpb')}
|
|
>
|
|
Preview
|
|
</button>
|
|
</div>
|
|
<br />
|
|
{preview}
|
|
</>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<CodeBlock value={url.toString()} language="plaintext" />
|
|
{preview}
|
|
</>
|
|
);
|
|
};
|
|
|
|
function formatBytes(bytes: number, decimals = 2): string {
|
|
if (bytes === 0) return '0 B';
|
|
|
|
const k = 1000;
|
|
const dm = decimals < 0 ? 0 : decimals;
|
|
const sizes = ['B', 'kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
|
|
|
|
const sign = bytes < 0 ? '-' : '';
|
|
const absoluteBytes = Math.abs(bytes);
|
|
const i = Math.min(
|
|
Math.floor(Math.log10(absoluteBytes) / Math.log10(k)),
|
|
sizes.length - 1,
|
|
);
|
|
|
|
const value = absoluteBytes / k ** i;
|
|
return `${sign}${value.toFixed(dm)} ${sizes[i]}`;
|
|
}
|
|
|
|
const rootNode = document.getElementById('root');
|
|
if (rootNode) {
|
|
const root = createRoot(rootNode);
|
|
root.render(
|
|
<StrictMode>
|
|
<ProjectForm />
|
|
</StrictMode>,
|
|
);
|
|
}
|