Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8b556ac5cb | |||
| 0ecac6533c |
+1
-1
@@ -3,5 +3,5 @@
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "es5",
|
||||
"printWidth": 120
|
||||
"printWidth": 80
|
||||
}
|
||||
@@ -18,7 +18,8 @@ export async function redirectRequest(
|
||||
|
||||
// Replace '/api/proxy/parserBackend' with backend URL, preserve everything after
|
||||
// e.g. '/api/proxy/extra/path?id=123' -> 'http://backend-server/extra/path?id=123'
|
||||
const backendUrl = backendBaseUrl + reqUrl.substring(proxyIndex + proxyUrl.length)
|
||||
const backendUrl =
|
||||
backendBaseUrl + reqUrl.substring(proxyIndex + proxyUrl.length)
|
||||
// console.log('redirecting', req.url, 'to', backendUrl)
|
||||
|
||||
const backendRes = await fetch(backendUrl, {
|
||||
@@ -33,23 +34,28 @@ export async function redirectRequest(
|
||||
status: backendRes.status,
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Content-Type': backendRes.headers.get('content-type') || 'application/octet-stream',
|
||||
'Content-Type':
|
||||
backendRes.headers.get('content-type') ||
|
||||
'application/octet-stream',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/// Browsers send OPTIONS request sometimes to check webserver allowed headers and methods.
|
||||
/// My backend doesn't support such requests so use proxy to answer to it that all headers and methods are allowed
|
||||
export async function responseToOPTIONS(req: NextRequest): Promise<NextResponse> {
|
||||
/// Browsers send OPTIONS request sometimes to check webserver's allowed headers and methods.
|
||||
/// My C# backend doesn't support OPTIONS, so the proxy answer to OPTIONS request that all headers and methods are allowed.
|
||||
export async function responseToOPTIONS(
|
||||
req: NextRequest
|
||||
): Promise<NextResponse> {
|
||||
// console.log('proxy responding to CORS OPTIONS', req.url)
|
||||
|
||||
return new NextResponse(null, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, POST, PUT, PATCH, DELETE, HEAD',
|
||||
'Access-Control-Allow-Headers':
|
||||
req.headers.get('access-control-request-headers') || 'Content-Type, Authorization',
|
||||
'Access-Control-Allow-Methods':
|
||||
'GET, POST, PUT, PATCH, DELETE, HEAD',
|
||||
'Access-Control-Allow-Headers': '*',
|
||||
// req.headers.get('access-control-request-headers') || 'Content-Type, Authorization'
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// Directories in src/api/proxy/ are routes.
|
||||
// Their paths are stored here to simplify refactoring.
|
||||
export const PROXY_PATHS = {
|
||||
saveParserBackend: '/api/proxy/saveParserBackend',
|
||||
}
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { redirectRequest, responseToOPTIONS as respondToOPTIONS } from '@/app/api/proxy/functions'
|
||||
import {
|
||||
redirectRequest,
|
||||
responseToOPTIONS as respondToOPTIONS,
|
||||
} from '@/app/api/proxy/functions'
|
||||
import { PROXY_PATHS } from '@/app/api/proxy/paths'
|
||||
import { appConfig } from '@/lib/configLoader'
|
||||
|
||||
// Forwards the incoming request to the save parser backend.
|
||||
async function redirect(req: NextRequest): Promise<NextResponse> {
|
||||
return await redirectRequest(req, PROXY_PATHS.saveParserBackend, appConfig.services.saveParserBackend.url)
|
||||
return await redirectRequest(
|
||||
req,
|
||||
PROXY_PATHS.saveParserBackend,
|
||||
appConfig.services.saveParserBackend.url
|
||||
)
|
||||
}
|
||||
|
||||
// handlers for each request method on this route
|
||||
export const GET = redirect
|
||||
export const POST = redirect
|
||||
export const PUT = redirect
|
||||
|
||||
+28
-9
@@ -1,26 +1,41 @@
|
||||
'use client'
|
||||
|
||||
import { AgGridReact } from 'ag-grid-react'
|
||||
import { AllCommunityModule, themeQuartz } from 'ag-grid-community'
|
||||
import {
|
||||
AllCommunityModule,
|
||||
themeQuartz,
|
||||
colorSchemeDark,
|
||||
} from 'ag-grid-community'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { colorSchemeDark } from 'ag-grid-community'
|
||||
|
||||
// TODO: create tabs with general info, tables, plots
|
||||
|
||||
export default function BrowsePage() {
|
||||
// Page that shows a save file's parsed data in a grid.
|
||||
export default function BrowseSaveDataPage() {
|
||||
const [agTheme, setAgTheme] = useState(themeQuartz)
|
||||
|
||||
// Keep the grid theme in sync with the browser's light/dark color scheme.
|
||||
useEffect(() => {
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
setAgTheme(prefersDark ? themeQuartz.withPart(colorSchemeDark) : themeQuartz)
|
||||
const prefersDark = window.matchMedia(
|
||||
'(prefers-color-scheme: dark)'
|
||||
).matches
|
||||
setAgTheme(
|
||||
prefersDark ? themeQuartz.withPart(colorSchemeDark) : themeQuartz
|
||||
)
|
||||
|
||||
const listener = (e: MediaQueryListEvent) => {
|
||||
setAgTheme(e.matches ? themeQuartz.withPart(colorSchemeDark) : themeQuartz)
|
||||
setAgTheme(
|
||||
e.matches ? themeQuartz.withPart(colorSchemeDark) : themeQuartz
|
||||
)
|
||||
}
|
||||
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', listener)
|
||||
window
|
||||
.matchMedia('(prefers-color-scheme: dark)')
|
||||
.addEventListener('change', listener)
|
||||
return () => {
|
||||
window.matchMedia('(prefers-color-scheme: dark)').removeEventListener('change', listener)
|
||||
window
|
||||
.matchMedia('(prefers-color-scheme: dark)')
|
||||
.removeEventListener('change', listener)
|
||||
}
|
||||
}, [])
|
||||
|
||||
@@ -34,7 +49,11 @@ export default function BrowsePage() {
|
||||
{ id: 2, col1: 'Dark Mode', col2: 'Support' },
|
||||
]}
|
||||
columnDefs={[
|
||||
{ field: 'col1', headerName: 'Column 1', filter: 'agTextColumnFilter' },
|
||||
{
|
||||
field: 'col1',
|
||||
headerName: 'Column 1',
|
||||
filter: 'agTextColumnFilter',
|
||||
},
|
||||
{ field: 'col2', headerName: 'Column 2' },
|
||||
]}
|
||||
/>
|
||||
|
||||
+5
-1
@@ -8,7 +8,11 @@ export const metadata: Metadata = {
|
||||
title: 'Home',
|
||||
}
|
||||
|
||||
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
||||
// Root layout wrapping every page with the navbar and shared styles.
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{ children: React.ReactNode }>) {
|
||||
// Register the ag-grid community features
|
||||
ModuleRegistry.registerModules([AllCommunityModule])
|
||||
return (
|
||||
<html lang="en">
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState } from 'react'
|
||||
import { authService } from '@/services/AuthService'
|
||||
import FormError from '@/components/FormError'
|
||||
|
||||
// Page with the login form.
|
||||
export default function LoginPage() {
|
||||
const [formData, setFormData] = useState({
|
||||
email: '',
|
||||
@@ -13,20 +14,26 @@ export default function LoginPage() {
|
||||
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Update the form data with new field value
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value, type, checked } = e.target
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
// checkboxes store value in property "checked"
|
||||
[name]: type === 'checkbox' ? checked : value,
|
||||
}))
|
||||
}
|
||||
|
||||
// Submit the form data to the auth service or show an error on failure.
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
try {
|
||||
console.log('Log in:', formData)
|
||||
const result = await authService.login(formData.email, formData.password)
|
||||
const result = await authService.login(
|
||||
formData.email,
|
||||
formData.password
|
||||
)
|
||||
console.log('Logged in:', result)
|
||||
} catch (err: any) {
|
||||
console.error(err.message)
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation'
|
||||
import { authService } from '@/services/AuthService'
|
||||
import FormError from '@/components/FormError'
|
||||
|
||||
// Page with the registration form.
|
||||
export default function RegisterPage() {
|
||||
const router = useRouter()
|
||||
|
||||
@@ -17,6 +18,7 @@ export default function RegisterPage() {
|
||||
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Update the matching form field by input name.
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
@@ -24,6 +26,7 @@ export default function RegisterPage() {
|
||||
}))
|
||||
}
|
||||
|
||||
// Validate the passwords match, register the user, then redirect to login.
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
@@ -33,7 +36,11 @@ export default function RegisterPage() {
|
||||
}
|
||||
|
||||
console.log('Register', formData)
|
||||
const result = await authService.register(formData.name, formData.email, formData.password)
|
||||
const result = await authService.register(
|
||||
formData.name,
|
||||
formData.email,
|
||||
formData.password
|
||||
)
|
||||
console.log('Registered:', result)
|
||||
router.push('/login') // Redirect after success
|
||||
} catch (err: any) {
|
||||
|
||||
@@ -2,8 +2,9 @@ interface FormErrorProps {
|
||||
message: string | null
|
||||
}
|
||||
|
||||
// Shows a red alert box with an error message, or nothing if there is no message.
|
||||
export default function FormError(props: FormErrorProps) {
|
||||
if (!props.message || props.message === '') return null
|
||||
if (!props.message) return null
|
||||
|
||||
return (
|
||||
<div className="alert alert-danger" role="alert">
|
||||
|
||||
@@ -3,9 +3,12 @@
|
||||
import Link from 'next/link'
|
||||
import { font_Hack } from '@/lib/myFonts'
|
||||
|
||||
// Top navigation bar with a home link and login/register buttons.
|
||||
export default function Navbar() {
|
||||
return (
|
||||
<nav className={`navbar navbar-expand-lg navbar-dark bg-dark px-3 ${font_Hack.className}`}>
|
||||
<nav
|
||||
className={`navbar navbar-expand-lg navbar-dark bg-dark px-3 ${font_Hack.className}`}
|
||||
>
|
||||
<div className="container-fluid">
|
||||
{/* Left: Home Link */}
|
||||
<Link href="/" className="navbar-brand">
|
||||
@@ -14,10 +17,16 @@ export default function Navbar() {
|
||||
|
||||
{/* Right: Login/Register Buttons */}
|
||||
<div className="d-flex gap-2 ms-auto">
|
||||
<Link href="/login" className="btn btn-outline-light rounded-pill px-4">
|
||||
<Link
|
||||
href="/login"
|
||||
className="btn btn-outline-light rounded-pill px-4"
|
||||
>
|
||||
Log in
|
||||
</Link>
|
||||
<Link href="/register" className="btn btn-primary rounded-pill px-4">
|
||||
<Link
|
||||
href="/register"
|
||||
className="btn btn-primary rounded-pill px-4"
|
||||
>
|
||||
Register
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
'use client'
|
||||
|
||||
import { saveParserBackendService, SaveStatusResponse, UploadSaveResponse } from '@/services/SaveParserBackendService'
|
||||
import {
|
||||
saveParserBackendService,
|
||||
SaveStatusResponse,
|
||||
UploadSaveResponse,
|
||||
} from '@/services/SaveParserBackendService'
|
||||
import { useState } from 'react'
|
||||
import FormError from './FormError'
|
||||
|
||||
@@ -12,24 +16,32 @@ export enum GameEnum {
|
||||
//TODO: move to separate page
|
||||
//TODO: add redirect to ?id={id} to see status of long parsing save
|
||||
|
||||
// Dialog for picking a game and save file, uploading it, and polling the parse status until done.
|
||||
export default function SaveFileUploadingDialog() {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null)
|
||||
const [game, setGame] = useState<GameEnum>(GameEnum.EU4)
|
||||
const [uploadDisabled, setUploadDisabled] = useState(false)
|
||||
const [saveStatus, setSaveStatus] = useState<SaveStatusResponse | null>(null)
|
||||
const [uploadResult, setUploadResult] = useState<UploadSaveResponse | null>(null)
|
||||
const [saveStatus, setSaveStatus] = useState<SaveStatusResponse | null>(
|
||||
null
|
||||
)
|
||||
const [uploadResult, setUploadResult] = useState<UploadSaveResponse | null>(
|
||||
null
|
||||
)
|
||||
|
||||
// Update the selected game from the dropdown.
|
||||
const handleGameSelect = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
setGame(e.target.value as GameEnum)
|
||||
}
|
||||
|
||||
// Store the file chosen in the file picker.
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files[0]) {
|
||||
setSelectedFile(e.target.files[0])
|
||||
}
|
||||
}
|
||||
|
||||
// Uploads the selected save file, then repeatedly polls its status until parsing is done or an error occurs.
|
||||
const handleUpload = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
@@ -38,14 +50,21 @@ export default function SaveFileUploadingDialog() {
|
||||
if (!selectedFile) return
|
||||
|
||||
console.log('Uploading file:', selectedFile.name)
|
||||
const uploadResponse = await saveParserBackendService.uploadSave(game, selectedFile)
|
||||
const uploadResponse = await saveParserBackendService.uploadSave(
|
||||
game,
|
||||
selectedFile
|
||||
)
|
||||
console.log('Upload response:', uploadResponse)
|
||||
setUploadResult(uploadResponse)
|
||||
|
||||
const pollInterval = 1000 // ms
|
||||
// Poll the backend every second for the parsing status until it reports "done".
|
||||
const intervalId = setInterval(async () => {
|
||||
try {
|
||||
const statusResponse = await saveParserBackendService.getSaveStatus(uploadResponse.id)
|
||||
const statusResponse =
|
||||
await saveParserBackendService.getSaveStatus(
|
||||
uploadResponse.id
|
||||
)
|
||||
console.log('Save status:', statusResponse.status)
|
||||
setSaveStatus(statusResponse)
|
||||
if (statusResponse.status.toLowerCase() === 'done') {
|
||||
@@ -66,6 +85,7 @@ export default function SaveFileUploadingDialog() {
|
||||
}
|
||||
}
|
||||
|
||||
// Build the <option> list from the GameEnum values.
|
||||
const gameEnumOptions = Object.values(GameEnum).map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{value}
|
||||
@@ -94,7 +114,12 @@ export default function SaveFileUploadingDialog() {
|
||||
<label htmlFor="fileInput" className="form-label">
|
||||
Select File
|
||||
</label>
|
||||
<input id="fileInput" type="file" className="form-control mb-3" onChange={handleFileChange} />
|
||||
<input
|
||||
id="fileInput"
|
||||
type="file"
|
||||
className="form-control mb-3"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
|
||||
<button
|
||||
className="btn btn-primary w-100 mt-1 mb-3"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
'server'
|
||||
// Default app configuration, values can be overrided in runtimeConfig.json
|
||||
export const defaultConfig = {
|
||||
services: {
|
||||
saveParserBackend: {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import '@/lib/customConsoleLog'
|
||||
// imports for both server and client go here
|
||||
|
||||
export function register() {
|
||||
// run once when the server starts.
|
||||
export async function register() {
|
||||
console.log('hello from instrumentation.ts!')
|
||||
await import('@/lib/customConsoleLog') // replaces console.log
|
||||
await import('@/lib/configLoader') // forces loadConfig() to run at server startup
|
||||
}
|
||||
|
||||
@@ -5,14 +5,17 @@ import { AppConfig, defaultConfig } from '@/config/AppConfig'
|
||||
|
||||
const configFilePath = path.join(process.cwd(), 'runtimeConfig.json')
|
||||
|
||||
// Reads runtimeConfig.json (merging it over the defaults) and creates new config file if missing
|
||||
function loadConfig(): AppConfig {
|
||||
let config = defaultConfig
|
||||
|
||||
if (fs.existsSync(configFilePath)) {
|
||||
console.log(`reading config file '${configFilePath}'`)
|
||||
const raw = fs.readFileSync(configFilePath, 'utf-8')
|
||||
const parsed = JSON.parse(raw)
|
||||
config = { ...defaultConfig, ...parsed }
|
||||
} else {
|
||||
console.log(`creating default config file '${configFilePath}'`)
|
||||
fs.mkdirSync(path.dirname(configFilePath), { recursive: true })
|
||||
}
|
||||
fs.writeFileSync(configFilePath, JSON.stringify(defaultConfig, null, 4))
|
||||
@@ -20,4 +23,5 @@ function loadConfig(): AppConfig {
|
||||
return config
|
||||
}
|
||||
|
||||
// Run once at first file import
|
||||
export const appConfig = loadConfig()
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
let _custom_console_log_injected = false
|
||||
if (!_custom_console_log_injected) {
|
||||
_custom_console_log_injected = true
|
||||
// Replaces console.log with a version that prefixes a timestamp
|
||||
function injectConsoleLog() {
|
||||
const originalLog = console.log
|
||||
|
||||
function customLog(message?: any, ...optionalParams: any[]) {
|
||||
const now = new Date()
|
||||
const timestamp = now.toTimeString().split(' ')[0] // hh:mm:ss
|
||||
originalLog(`[${timestamp}] ${message}`, ...optionalParams, ':3')
|
||||
originalLog(`[${timestamp}] ${message}`, ...optionalParams)
|
||||
}
|
||||
|
||||
console.log = customLog
|
||||
return null
|
||||
}
|
||||
|
||||
// Run once at first file import
|
||||
export const _inject_run_once = injectConsoleLog()
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import localFont from 'next/font/local'
|
||||
import { IBM_Plex_Sans } from 'next/font/google'
|
||||
|
||||
// Loads the Google-hosted IBM Plex Sans font.
|
||||
// It is downloaded to build directory during build.
|
||||
export const font_IbmPlexSans = IBM_Plex_Sans({
|
||||
subsets: ['latin'],
|
||||
weight: ['400', '500', '700'],
|
||||
@@ -8,6 +10,7 @@ export const font_IbmPlexSans = IBM_Plex_Sans({
|
||||
variable: '--font-ibm-plex-sans',
|
||||
})
|
||||
|
||||
// Loads the local Hack font used by the navbar.
|
||||
export const font_Hack = localFont({
|
||||
src: [
|
||||
{
|
||||
|
||||
@@ -3,11 +3,13 @@
|
||||
class AuthService {
|
||||
private API_BASE = '/api/auth'
|
||||
|
||||
// Hashes the password before sending it to the backend
|
||||
private hashPassword(password: string) {
|
||||
// TODO: password hashing
|
||||
return password
|
||||
}
|
||||
|
||||
// Logs in with email and password, returning the parsed JSON response.
|
||||
async login(email: string, password: string) {
|
||||
const response = await fetch(`${this.API_BASE}/login`, {
|
||||
method: 'POST',
|
||||
@@ -25,6 +27,7 @@ class AuthService {
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
// Registers a new user, returning the parsed JSON response.
|
||||
async register(name: string, email: string, password: string) {
|
||||
const response = await fetch(`${this.API_BASE}/register`, {
|
||||
method: 'POST',
|
||||
|
||||
@@ -11,18 +11,20 @@ export interface SaveStatusResponse {
|
||||
uploadDateTime: string
|
||||
}
|
||||
|
||||
// Tries to read the response body as string, returning an empty string if that fails.
|
||||
async function getBodyTextOrEmpty(res: Response): Promise<string> {
|
||||
try {
|
||||
return await res.text()
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
export class SaveParserBackendService {
|
||||
private API_BASE = PROXY_PATHS.saveParserBackend
|
||||
|
||||
private async tryGetResponseJson(res: Response): Promise<string> {
|
||||
try {
|
||||
const body = await res.json()
|
||||
return JSON.stringify(body)
|
||||
} catch {}
|
||||
return ''
|
||||
}
|
||||
|
||||
//TODO: save size limit 30 mb
|
||||
// Uploads a save file for the given game and returns its assigned id.
|
||||
async uploadSave(game: string, file: File): Promise<UploadSaveResponse> {
|
||||
const url = `${this.API_BASE}/uploadSave?game=${encodeURIComponent(game)}`
|
||||
const response = await fetch(url, {
|
||||
@@ -33,33 +35,40 @@ export class SaveParserBackendService {
|
||||
body: file,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to upload save: ${response.statusText} ${await this.tryGetResponseJson(response)}`)
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
async getSaveStatus(id: string): Promise<SaveStatusResponse> {
|
||||
const url = `${this.API_BASE}/getSaveStatus?id=${encodeURIComponent(id)}`
|
||||
const response = await fetch(url)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to get save status: ${response.statusText} ${await this.tryGetResponseJson(response)}`
|
||||
`Failed to upload save: ${response.statusText} ` +
|
||||
(await getBodyTextOrEmpty(response))
|
||||
)
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
// Fetches the current parsing status of a previously uploaded save.
|
||||
async getSaveStatus(id: string): Promise<SaveStatusResponse> {
|
||||
const url = `${this.API_BASE}/getSaveStatus?id=${encodeURIComponent(id)}`
|
||||
const response = await fetch(url)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to get save status: ${response.statusText} ` +
|
||||
(await getBodyTextOrEmpty(response))
|
||||
)
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
// Fetches the fully parsed data for a save once its parsing is done.
|
||||
async getParsedSave(id: string): Promise<Record<string, any>> {
|
||||
const url = `${this.API_BASE}/getParsedSave?id=${encodeURIComponent(id)}`
|
||||
const response = await fetch(url)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to get parsed save: ${response.statusText} ${await this.tryGetResponseJson(response)}`
|
||||
`Failed to get parsed save: ${response.statusText} ` +
|
||||
(await getBodyTextOrEmpty(response))
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user