Compare commits
5 Commits
95880ca080
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 9c25922a3b | |||
| e9ede28098 | |||
| 656bc318d6 | |||
| 8b556ac5cb | |||
| 0ecac6533c |
+1
-1
@@ -3,5 +3,5 @@
|
|||||||
"semi": false,
|
"semi": false,
|
||||||
"singleQuote": true,
|
"singleQuote": true,
|
||||||
"trailingComma": "es5",
|
"trailingComma": "es5",
|
||||||
"printWidth": 120
|
"printWidth": 80
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
Guidance for AI coding agents working in this repository.
|
||||||
|
|
||||||
|
## Project
|
||||||
|
|
||||||
|
Next.js frontend for a Paradox save file parser. It uploads a game save file to a
|
||||||
|
backend service, polls until parsing finishes, and displays the result in a
|
||||||
|
grid.
|
||||||
|
|
||||||
|
- **Next.js 16** (App Router, Turbopack) with **React 19**
|
||||||
|
- **TypeScript 6**, **ESLint 9** (flat config), **Prettier 3**
|
||||||
|
- **ag-grid** for the data grid, **Bootstrap 5** for styling
|
||||||
|
|
||||||
|
Source files live in `src/`:
|
||||||
|
|
||||||
|
- `app/` for routes,
|
||||||
|
- `components/` for shared UI
|
||||||
|
- `services/` for backend calls
|
||||||
|
- `lib/` for helpers
|
||||||
|
- `config/` for app config
|
||||||
|
- `types/` for .d.ts type declaration files
|
||||||
|
|
||||||
|
## Terminal
|
||||||
|
|
||||||
|
**On Windows, prefer `bash.exe`** — but only a real MSYS2, Cygwin, or
|
||||||
|
MinGW/Git Bash installation, for example `D:\msys2\usr\bin\bash.exe`.
|
||||||
|
|
||||||
|
**Never use `C:\Windows\System32\bash.exe`.** Despite its name it is not a
|
||||||
|
POSIX bash; it is the launcher stub for WSL. Do not start WSL to run ordinary
|
||||||
|
commands.
|
||||||
|
|
||||||
|
If no MSYS/Cygwin/MinGW bash is installed, **use PowerShell** as the terminal.
|
||||||
|
PowerShell is the fallback, never WSL.
|
||||||
|
|
||||||
|
If bash reports `command not found` for `node` or `npm`, that is a `PATH`
|
||||||
|
problem rather than a missing install: MSYS2 defaults `MSYS2_PATH_TYPE` to
|
||||||
|
`minimal`, which discards the Windows `PATH`. Setting it to `inherit` fixes it.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run dev # dev server
|
||||||
|
npm run build # production build
|
||||||
|
npm run lint # eslint
|
||||||
|
npm run format # prettier, rewrites files in src/
|
||||||
|
npx tsc --noEmit # typecheck
|
||||||
|
```
|
||||||
|
|
||||||
|
### Checking your work
|
||||||
|
|
||||||
|
**Always run `npm run lint` and `npm run build` after changing code.** They are
|
||||||
|
the primary way to find errors in this project, and they catch different
|
||||||
|
things:
|
||||||
|
|
||||||
|
- `npm run lint` reports code errors and style violations. It must finish with
|
||||||
|
zero problems — no errors and no warnings.
|
||||||
|
- `npm run build` catches type errors and build-time problems that lint cannot
|
||||||
|
see, such as Node APIs leaking into the edge runtime. It must exit cleanly
|
||||||
|
and print no warnings.
|
||||||
|
|
||||||
|
`npx tsc --noEmit` is a faster way to check types alone while iterating, but it
|
||||||
|
does not replace a full build.
|
||||||
|
|
||||||
|
Do not report a change as finished until both commands pass.
|
||||||
|
|
||||||
|
## Version control
|
||||||
|
|
||||||
|
This project is managed with **git**.
|
||||||
|
|
||||||
|
**Read-only git commands are fine to use without asking.**
|
||||||
|
`git status`, `git log`, `git diff`, `git show`, `git blame` and similar inspection commands
|
||||||
|
may be run freely.
|
||||||
|
|
||||||
|
**Never run a git command that changes the repository without the user's
|
||||||
|
explicit permission.**
|
||||||
|
This is a strict ban, and it covers everything that writes to the history, the index,
|
||||||
|
or the working tree including `add`, `commit`, `checkout`, `restore`, `reset`,
|
||||||
|
`stash`, `branch`, `merge`, `rebase`, `push`, `rm`, etc.
|
||||||
|
|
||||||
|
Ask first and wait for a clear yes before running such git command.
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
Prettier settings live in `.prettierrc` and are not negotiable: **4 spaces, no
|
||||||
|
semicolons, single quotes, 80 column width**. Run `npm run format` after
|
||||||
|
editing, or match the surrounding style exactly.
|
||||||
|
|
||||||
|
Import from within `src/` using the `@/` alias, for example
|
||||||
|
`import { getMsgFromError } from '@/lib/errors'`.
|
||||||
|
|
||||||
|
Write doc comments as JSDoc (`/** ... */`), which editors show on hover.
|
||||||
|
Plain `//` and `///` comments do not appear in tooltips.
|
||||||
|
In `.ts` files put only prose in the JSDoc and let the signature carry the types;
|
||||||
|
do not duplicate types in `{braces}`.
|
||||||
|
|
||||||
|
Catch blocks should not annotate the error. Let it be `unknown` and convert it
|
||||||
|
with the shared helper:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
} catch (err) {
|
||||||
|
const errMsg = getMsgFromError(err)
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Avoid `any`; `no-explicit-any` is enforced. Prefer `unknown` plus narrowing.
|
||||||
|
|
||||||
|
## Things that will bite you
|
||||||
|
|
||||||
|
**TypeScript is pinned to 6 on purpose.** Do not upgrade it to 7. The Next.js
|
||||||
|
ESLint config depends on typescript-eslint, which refuses to run under
|
||||||
|
TypeScript 7, and that would break `npm run lint` entirely.
|
||||||
|
|
||||||
|
**`instrumentation.ts` is compiled for the edge runtime too and it can't be disabled.**
|
||||||
|
Anything using `fs`, `path`, or other Node APIs must be imported behind the
|
||||||
|
`process.env.NEXT_RUNTIME === 'nodejs'` guard, or the build warns and pulls
|
||||||
|
Node-only code into the edge bundle.
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
import nextCoreWebVitals from 'eslint-config-next/core-web-vitals'
|
||||||
|
import nextTypescript from 'eslint-config-next/typescript'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['.next/**', 'node_modules/**', 'next-env.d.ts']),
|
||||||
|
nextCoreWebVitals,
|
||||||
|
nextTypescript,
|
||||||
|
])
|
||||||
Generated
+5501
-274
File diff suppressed because it is too large
Load Diff
+10
-5
@@ -6,21 +6,26 @@
|
|||||||
"dev": "next dev --turbopack",
|
"dev": "next dev --turbopack",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint",
|
"lint": "eslint",
|
||||||
"format": "npx prettier src --write"
|
"format": "npx prettier src --write"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ag-grid-react": "^33",
|
"ag-grid-react": "^36",
|
||||||
"bootstrap": "^5",
|
"bootstrap": "^5",
|
||||||
"next": "^15",
|
"next": "^16",
|
||||||
"react": "^19",
|
"react": "^19",
|
||||||
"react-dom": "^19"
|
"react-dom": "^19"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22",
|
"@types/node": "^26",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
"eslint": "^9",
|
||||||
|
"eslint-config-next": "^16",
|
||||||
"prettier": "^3",
|
"prettier": "^3",
|
||||||
"typescript": "^5"
|
"typescript": "^6"
|
||||||
|
},
|
||||||
|
"allowScripts": {
|
||||||
|
"unrs-resolver": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import type { NextRequest } from 'next/server'
|
import type { NextRequest } from 'next/server'
|
||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
|
|
||||||
/// Resend request to backend.
|
/**
|
||||||
/// Body is being sent as stream.
|
* Resend request to backend.
|
||||||
|
* Body is being sent as stream.
|
||||||
|
*/
|
||||||
export async function redirectRequest(
|
export async function redirectRequest(
|
||||||
req: NextRequest,
|
req: NextRequest,
|
||||||
proxyUrl: string,
|
proxyUrl: string,
|
||||||
@@ -18,38 +20,48 @@ export async function redirectRequest(
|
|||||||
|
|
||||||
// Replace '/api/proxy/parserBackend' with backend URL, preserve everything after
|
// 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'
|
// 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)
|
// console.log('redirecting', req.url, 'to', backendUrl)
|
||||||
|
|
||||||
const backendRes = await fetch(backendUrl, {
|
const backendRes = await fetch(backendUrl, {
|
||||||
method: req.method,
|
method: req.method,
|
||||||
headers: req.headers,
|
headers: req.headers,
|
||||||
body: req.body,
|
body: req.body,
|
||||||
// @ts-ignore
|
duplex: 'half', // required by nodejs when the body is a stream, see src/types/fetch.d.ts
|
||||||
duplex: 'half', // some property required by nodejs but not defined in @types/node
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return new NextResponse(backendRes.body, {
|
return new NextResponse(backendRes.body, {
|
||||||
status: backendRes.status,
|
status: backendRes.status,
|
||||||
headers: {
|
headers: {
|
||||||
'Access-Control-Allow-Origin': '*',
|
'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
|
* Browsers send OPTIONS request sometimes to check webserver's allowed
|
||||||
export async function responseToOPTIONS(req: NextRequest): Promise<NextResponse> {
|
* 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 respondToOPTIONS(
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- required by the nextjs route handler signature
|
||||||
|
req: NextRequest
|
||||||
|
): Promise<NextResponse> {
|
||||||
// console.log('proxy responding to CORS OPTIONS', req.url)
|
// console.log('proxy responding to CORS OPTIONS', req.url)
|
||||||
|
|
||||||
return new NextResponse(null, {
|
return new NextResponse(null, {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: {
|
headers: {
|
||||||
'Access-Control-Allow-Origin': '*',
|
'Access-Control-Allow-Origin': '*',
|
||||||
'Access-Control-Allow-Methods': 'GET, POST, PUT, PATCH, DELETE, HEAD',
|
'Access-Control-Allow-Methods':
|
||||||
'Access-Control-Allow-Headers':
|
'GET, POST, PUT, PATCH, DELETE, HEAD',
|
||||||
req.headers.get('access-control-request-headers') || 'Content-Type, Authorization',
|
'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 = {
|
export const PROXY_PATHS = {
|
||||||
saveParserBackend: '/api/proxy/saveParserBackend',
|
saveParserBackend: '/api/proxy/saveParserBackend',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
import type { NextRequest } from 'next/server'
|
import type { NextRequest } from 'next/server'
|
||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
import { redirectRequest, responseToOPTIONS as respondToOPTIONS } from '@/app/api/proxy/functions'
|
import { redirectRequest, respondToOPTIONS } from '@/app/api/proxy/functions'
|
||||||
import { PROXY_PATHS } from '@/app/api/proxy/paths'
|
import { PROXY_PATHS } from '@/app/api/proxy/paths'
|
||||||
import { appConfig } from '@/lib/configLoader'
|
import { appConfig } from '@/lib/configLoader'
|
||||||
|
|
||||||
|
// Forwards the incoming request to the save parser backend.
|
||||||
async function redirect(req: NextRequest): Promise<NextResponse> {
|
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 GET = redirect
|
||||||
export const POST = redirect
|
export const POST = redirect
|
||||||
export const PUT = redirect
|
export const PUT = redirect
|
||||||
|
|||||||
+48
-20
@@ -1,28 +1,52 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { AgGridReact } from 'ag-grid-react'
|
import { AgGridReact } from 'ag-grid-react'
|
||||||
import { AllCommunityModule, themeQuartz } from 'ag-grid-community'
|
import {
|
||||||
import { useState, useEffect } from 'react'
|
AllCommunityModule,
|
||||||
import { colorSchemeDark } from 'ag-grid-community'
|
themeQuartz,
|
||||||
|
colorSchemeDark,
|
||||||
|
} from 'ag-grid-community'
|
||||||
|
import { useSyncExternalStore } from 'react'
|
||||||
|
|
||||||
|
const DARK_SCHEME_QUERY = '(prefers-color-scheme: dark)'
|
||||||
|
|
||||||
|
// add the vertical lines between cells
|
||||||
|
const BASE_THEME = themeQuartz.withParams({
|
||||||
|
columnBorder: true,
|
||||||
|
headerColumnBorder: true,
|
||||||
|
})
|
||||||
|
// Built once, so the grid keeps seeing the same theme object between renders.
|
||||||
|
const LIGHT_THEME = BASE_THEME
|
||||||
|
const DARK_THEME = BASE_THEME.withPart(colorSchemeDark)
|
||||||
|
|
||||||
|
// Notifies React whenever the browser's light/dark color scheme changes.
|
||||||
|
function subscribeToColorScheme(onChange: () => void) {
|
||||||
|
const mediaQuery = window.matchMedia(DARK_SCHEME_QUERY)
|
||||||
|
mediaQuery.addEventListener('change', onChange)
|
||||||
|
return () => mediaQuery.removeEventListener('change', onChange)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDarkSchemePreferred() {
|
||||||
|
return window.matchMedia(DARK_SCHEME_QUERY).matches
|
||||||
|
}
|
||||||
|
|
||||||
|
// The server has no color scheme, so it always prerenders the light theme.
|
||||||
|
function isDarkSchemePreferredOnServer() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// TODO: create tabs with general info, tables, plots
|
// TODO: create tabs with general info, tables, plots
|
||||||
|
|
||||||
export default function BrowsePage() {
|
// Page that shows a save file's parsed data in a grid.
|
||||||
const [agTheme, setAgTheme] = useState(themeQuartz)
|
export default function BrowseSaveDataPage() {
|
||||||
|
// Read the color scheme straight from the browser instead of mirroring it
|
||||||
useEffect(() => {
|
// into state, which would need an extra render to catch up.
|
||||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
const prefersDark = useSyncExternalStore(
|
||||||
setAgTheme(prefersDark ? themeQuartz.withPart(colorSchemeDark) : themeQuartz)
|
subscribeToColorScheme,
|
||||||
|
isDarkSchemePreferred,
|
||||||
const listener = (e: MediaQueryListEvent) => {
|
isDarkSchemePreferredOnServer
|
||||||
setAgTheme(e.matches ? themeQuartz.withPart(colorSchemeDark) : themeQuartz)
|
)
|
||||||
}
|
const agTheme = prefersDark ? DARK_THEME : LIGHT_THEME
|
||||||
|
|
||||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', listener)
|
|
||||||
return () => {
|
|
||||||
window.matchMedia('(prefers-color-scheme: dark)').removeEventListener('change', listener)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ height: '500px' }}>
|
<div style={{ height: '500px' }}>
|
||||||
@@ -34,7 +58,11 @@ export default function BrowsePage() {
|
|||||||
{ id: 2, col1: 'Dark Mode', col2: 'Support' },
|
{ id: 2, col1: 'Dark Mode', col2: 'Support' },
|
||||||
]}
|
]}
|
||||||
columnDefs={[
|
columnDefs={[
|
||||||
{ field: 'col1', headerName: 'Column 1', filter: 'agTextColumnFilter' },
|
{
|
||||||
|
field: 'col1',
|
||||||
|
headerName: 'Column 1',
|
||||||
|
filter: 'agTextColumnFilter',
|
||||||
|
},
|
||||||
{ field: 'col2', headerName: 'Column 2' },
|
{ field: 'col2', headerName: 'Column 2' },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|||||||
+5
-1
@@ -8,7 +8,11 @@ export const metadata: Metadata = {
|
|||||||
title: 'Home',
|
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])
|
ModuleRegistry.registerModules([AllCommunityModule])
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
|||||||
+13
-4
@@ -3,7 +3,9 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { authService } from '@/services/AuthService'
|
import { authService } from '@/services/AuthService'
|
||||||
import FormError from '@/components/FormError'
|
import FormError from '@/components/FormError'
|
||||||
|
import { getMsgFromError } from '@/lib/errors'
|
||||||
|
|
||||||
|
// Page with the login form.
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
email: '',
|
email: '',
|
||||||
@@ -13,24 +15,31 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Update the form data with new field value
|
||||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const { name, value, type, checked } = e.target
|
const { name, value, type, checked } = e.target
|
||||||
setFormData((prev) => ({
|
setFormData((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
|
// checkboxes store value in property "checked"
|
||||||
[name]: type === 'checkbox' ? checked : value,
|
[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) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
console.log('Log in:', formData)
|
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)
|
console.log('Logged in:', result)
|
||||||
} catch (err: any) {
|
} catch (err) {
|
||||||
console.error(err.message)
|
const errMsg = getMsgFromError(err)
|
||||||
setError(err.message)
|
console.error(errMsg)
|
||||||
|
setError(errMsg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import { useState } from 'react'
|
|||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { authService } from '@/services/AuthService'
|
import { authService } from '@/services/AuthService'
|
||||||
import FormError from '@/components/FormError'
|
import FormError from '@/components/FormError'
|
||||||
|
import { getMsgFromError } from '@/lib/errors'
|
||||||
|
|
||||||
|
// Page with the registration form.
|
||||||
export default function RegisterPage() {
|
export default function RegisterPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
@@ -17,6 +19,7 @@ export default function RegisterPage() {
|
|||||||
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Update the matching form field by input name.
|
||||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setFormData((prev) => ({
|
setFormData((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -24,6 +27,7 @@ export default function RegisterPage() {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate the passwords match, register the user, then redirect to login.
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setError(null)
|
setError(null)
|
||||||
@@ -33,12 +37,17 @@ export default function RegisterPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.log('Register', formData)
|
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)
|
console.log('Registered:', result)
|
||||||
router.push('/login') // Redirect after success
|
router.push('/login') // Redirect after success
|
||||||
} catch (err: any) {
|
} catch (err) {
|
||||||
console.error(err.message)
|
const errMsg = getMsgFromError(err)
|
||||||
setError(err.message)
|
console.error(errMsg)
|
||||||
|
setError(errMsg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ interface FormErrorProps {
|
|||||||
message: string | null
|
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) {
|
export default function FormError(props: FormErrorProps) {
|
||||||
if (!props.message || props.message === '') return null
|
if (!props.message) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="alert alert-danger" role="alert">
|
<div className="alert alert-danger" role="alert">
|
||||||
|
|||||||
@@ -3,9 +3,12 @@
|
|||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { font_Hack } from '@/lib/myFonts'
|
import { font_Hack } from '@/lib/myFonts'
|
||||||
|
|
||||||
|
// Top navigation bar with a home link and login/register buttons.
|
||||||
export default function Navbar() {
|
export default function Navbar() {
|
||||||
return (
|
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">
|
<div className="container-fluid">
|
||||||
{/* Left: Home Link */}
|
{/* Left: Home Link */}
|
||||||
<Link href="/" className="navbar-brand">
|
<Link href="/" className="navbar-brand">
|
||||||
@@ -14,10 +17,16 @@ export default function Navbar() {
|
|||||||
|
|
||||||
{/* Right: Login/Register Buttons */}
|
{/* Right: Login/Register Buttons */}
|
||||||
<div className="d-flex gap-2 ms-auto">
|
<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
|
Log in
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/register" className="btn btn-primary rounded-pill px-4">
|
<Link
|
||||||
|
href="/register"
|
||||||
|
className="btn btn-primary rounded-pill px-4"
|
||||||
|
>
|
||||||
Register
|
Register
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { saveParserBackendService, SaveStatusResponse, UploadSaveResponse } from '@/services/SaveParserBackendService'
|
import {
|
||||||
|
saveParserBackendService,
|
||||||
|
SaveStatusResponse,
|
||||||
|
UploadSaveResponse,
|
||||||
|
} from '@/services/SaveParserBackendService'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import FormError from './FormError'
|
import FormError from './FormError'
|
||||||
|
import { getMsgFromError } from '@/lib/errors'
|
||||||
|
|
||||||
export enum GameEnum {
|
export enum GameEnum {
|
||||||
EU4 = 'eu4',
|
EU4 = 'eu4',
|
||||||
@@ -12,24 +17,32 @@ export enum GameEnum {
|
|||||||
//TODO: move to separate page
|
//TODO: move to separate page
|
||||||
//TODO: add redirect to ?id={id} to see status of long parsing save
|
//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() {
|
export default function SaveFileUploadingDialog() {
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [selectedFile, setSelectedFile] = useState<File | null>(null)
|
const [selectedFile, setSelectedFile] = useState<File | null>(null)
|
||||||
const [game, setGame] = useState<GameEnum>(GameEnum.EU4)
|
const [game, setGame] = useState<GameEnum>(GameEnum.EU4)
|
||||||
const [uploadDisabled, setUploadDisabled] = useState(false)
|
const [uploadDisabled, setUploadDisabled] = useState(false)
|
||||||
const [saveStatus, setSaveStatus] = useState<SaveStatusResponse | null>(null)
|
const [saveStatus, setSaveStatus] = useState<SaveStatusResponse | null>(
|
||||||
const [uploadResult, setUploadResult] = useState<UploadSaveResponse | null>(null)
|
null
|
||||||
|
)
|
||||||
|
const [uploadResult, setUploadResult] = useState<UploadSaveResponse | null>(
|
||||||
|
null
|
||||||
|
)
|
||||||
|
|
||||||
|
// Update the selected game from the dropdown.
|
||||||
const handleGameSelect = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
const handleGameSelect = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||||
setGame(e.target.value as GameEnum)
|
setGame(e.target.value as GameEnum)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Store the file chosen in the file picker.
|
||||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
if (e.target.files && e.target.files[0]) {
|
if (e.target.files && e.target.files[0]) {
|
||||||
setSelectedFile(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) => {
|
const handleUpload = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setError(null)
|
setError(null)
|
||||||
@@ -38,34 +51,44 @@ export default function SaveFileUploadingDialog() {
|
|||||||
if (!selectedFile) return
|
if (!selectedFile) return
|
||||||
|
|
||||||
console.log('Uploading file:', selectedFile.name)
|
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)
|
console.log('Upload response:', uploadResponse)
|
||||||
setUploadResult(uploadResponse)
|
setUploadResult(uploadResponse)
|
||||||
|
|
||||||
const pollInterval = 1000 // ms
|
const pollInterval = 1000 // ms
|
||||||
|
// Poll the backend every second for the parsing status until it reports "done".
|
||||||
const intervalId = setInterval(async () => {
|
const intervalId = setInterval(async () => {
|
||||||
try {
|
try {
|
||||||
const statusResponse = await saveParserBackendService.getSaveStatus(uploadResponse.id)
|
const statusResponse =
|
||||||
|
await saveParserBackendService.getSaveStatus(
|
||||||
|
uploadResponse.id
|
||||||
|
)
|
||||||
console.log('Save status:', statusResponse.status)
|
console.log('Save status:', statusResponse.status)
|
||||||
setSaveStatus(statusResponse)
|
setSaveStatus(statusResponse)
|
||||||
if (statusResponse.status.toLowerCase() === 'done') {
|
if (statusResponse.status.toLowerCase() === 'done') {
|
||||||
clearInterval(intervalId)
|
clearInterval(intervalId)
|
||||||
setUploadDisabled(false)
|
setUploadDisabled(false)
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err) {
|
||||||
console.error('Error while polling status:', err)
|
const errMsg = getMsgFromError(err)
|
||||||
|
console.error('Error while polling status:', errMsg)
|
||||||
clearInterval(intervalId)
|
clearInterval(intervalId)
|
||||||
setError(err.message)
|
setError(errMsg)
|
||||||
setUploadDisabled(false)
|
setUploadDisabled(false)
|
||||||
}
|
}
|
||||||
}, pollInterval)
|
}, pollInterval)
|
||||||
} catch (err: any) {
|
} catch (err) {
|
||||||
console.error(err.message)
|
const errMsg = getMsgFromError(err)
|
||||||
setError(err.message)
|
console.error(errMsg)
|
||||||
|
setError(errMsg)
|
||||||
setUploadDisabled(false)
|
setUploadDisabled(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build the <option> list from the GameEnum values.
|
||||||
const gameEnumOptions = Object.values(GameEnum).map((value) => (
|
const gameEnumOptions = Object.values(GameEnum).map((value) => (
|
||||||
<option key={value} value={value}>
|
<option key={value} value={value}>
|
||||||
{value}
|
{value}
|
||||||
@@ -94,7 +117,12 @@ export default function SaveFileUploadingDialog() {
|
|||||||
<label htmlFor="fileInput" className="form-label">
|
<label htmlFor="fileInput" className="form-label">
|
||||||
Select File
|
Select File
|
||||||
</label>
|
</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
|
<button
|
||||||
className="btn btn-primary w-100 mt-1 mb-3"
|
className="btn btn-primary w-100 mt-1 mb-3"
|
||||||
@@ -120,7 +148,7 @@ export default function SaveFileUploadingDialog() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* add link to <ConditionalView cond={done}> /browse?id=${saveStatus.id} */}
|
{/* TODO: add link to <ConditionalView cond={done}> /browse?id=${saveStatus.id} */}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
'server'
|
'server'
|
||||||
|
// Default app configuration, values can be overrided in runtimeConfig.json
|
||||||
export const defaultConfig = {
|
export const defaultConfig = {
|
||||||
services: {
|
services: {
|
||||||
saveParserBackend: {
|
saveParserBackend: {
|
||||||
|
|||||||
+10
-2
@@ -1,5 +1,13 @@
|
|||||||
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!')
|
console.log('hello from instrumentation.ts!')
|
||||||
|
await import('@/lib/customConsoleLog') // replaces console.log
|
||||||
|
|
||||||
|
// Importing configLoader outside of this block will produce warning.
|
||||||
|
// Next.js compiles this file for some "edge runtime", where no fs and path modules exist.
|
||||||
|
if (process.env.NEXT_RUNTIME === 'nodejs') {
|
||||||
|
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')
|
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 {
|
function loadConfig(): AppConfig {
|
||||||
let config = defaultConfig
|
let config = defaultConfig
|
||||||
|
|
||||||
if (fs.existsSync(configFilePath)) {
|
if (fs.existsSync(configFilePath)) {
|
||||||
|
console.log(`reading config file '${configFilePath}'`)
|
||||||
const raw = fs.readFileSync(configFilePath, 'utf-8')
|
const raw = fs.readFileSync(configFilePath, 'utf-8')
|
||||||
const parsed = JSON.parse(raw)
|
const parsed = JSON.parse(raw)
|
||||||
config = { ...defaultConfig, ...parsed }
|
config = { ...defaultConfig, ...parsed }
|
||||||
} else {
|
} else {
|
||||||
|
console.log(`creating default config file '${configFilePath}'`)
|
||||||
fs.mkdirSync(path.dirname(configFilePath), { recursive: true })
|
fs.mkdirSync(path.dirname(configFilePath), { recursive: true })
|
||||||
}
|
}
|
||||||
fs.writeFileSync(configFilePath, JSON.stringify(defaultConfig, null, 4))
|
fs.writeFileSync(configFilePath, JSON.stringify(defaultConfig, null, 4))
|
||||||
@@ -20,4 +23,5 @@ function loadConfig(): AppConfig {
|
|||||||
return config
|
return config
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Run once at first file import
|
||||||
export const appConfig = loadConfig()
|
export const appConfig = loadConfig()
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
let _custom_console_log_injected = false
|
// Replaces console.log with a version that prefixes a timestamp
|
||||||
if (!_custom_console_log_injected) {
|
function injectConsoleLog() {
|
||||||
_custom_console_log_injected = true
|
|
||||||
const originalLog = console.log
|
const originalLog = console.log
|
||||||
|
|
||||||
function customLog(message?: any, ...optionalParams: any[]) {
|
function customLog(message?: unknown, ...optionalParams: unknown[]) {
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const timestamp = now.toTimeString().split(' ')[0] // hh:mm:ss
|
const timestamp = now.toTimeString().split(' ')[0] // hh:mm:ss
|
||||||
originalLog(`[${timestamp}] ${message}`, ...optionalParams, ':3')
|
originalLog(`[${timestamp}] ${message}`, ...optionalParams)
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log = customLog
|
console.log = customLog
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Run once at first file import
|
||||||
|
export const _inject_run_once = injectConsoleLog()
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
/**
|
||||||
|
* Extracts a readable message from an unknown thrown value, since anything
|
||||||
|
* (not just an Error) can be thrown and caught.
|
||||||
|
*/
|
||||||
|
export function getMsgFromError(err: unknown): string {
|
||||||
|
return err instanceof Error ? err.message : String(err)
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import localFont from 'next/font/local'
|
import localFont from 'next/font/local'
|
||||||
import { IBM_Plex_Sans } from 'next/font/google'
|
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({
|
export const font_IbmPlexSans = IBM_Plex_Sans({
|
||||||
subsets: ['latin'],
|
subsets: ['latin'],
|
||||||
weight: ['400', '500', '700'],
|
weight: ['400', '500', '700'],
|
||||||
@@ -8,6 +10,7 @@ export const font_IbmPlexSans = IBM_Plex_Sans({
|
|||||||
variable: '--font-ibm-plex-sans',
|
variable: '--font-ibm-plex-sans',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Loads the local Hack font used by the navbar.
|
||||||
export const font_Hack = localFont({
|
export const font_Hack = localFont({
|
||||||
src: [
|
src: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -3,11 +3,13 @@
|
|||||||
class AuthService {
|
class AuthService {
|
||||||
private API_BASE = '/api/auth'
|
private API_BASE = '/api/auth'
|
||||||
|
|
||||||
|
// Hashes the password before sending it to the backend
|
||||||
private hashPassword(password: string) {
|
private hashPassword(password: string) {
|
||||||
// TODO: password hashing
|
// TODO: password hashing
|
||||||
return password
|
return password
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Logs in with email and password, returning the parsed JSON response.
|
||||||
async login(email: string, password: string) {
|
async login(email: string, password: string) {
|
||||||
const response = await fetch(`${this.API_BASE}/login`, {
|
const response = await fetch(`${this.API_BASE}/login`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -25,6 +27,7 @@ class AuthService {
|
|||||||
return await response.json()
|
return await response.json()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Registers a new user, returning the parsed JSON response.
|
||||||
async register(name: string, email: string, password: string) {
|
async register(name: string, email: string, password: string) {
|
||||||
const response = await fetch(`${this.API_BASE}/register`, {
|
const response = await fetch(`${this.API_BASE}/register`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -11,18 +11,20 @@ export interface SaveStatusResponse {
|
|||||||
uploadDateTime: string
|
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 {
|
export class SaveParserBackendService {
|
||||||
private API_BASE = PROXY_PATHS.saveParserBackend
|
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
|
//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> {
|
async uploadSave(game: string, file: File): Promise<UploadSaveResponse> {
|
||||||
const url = `${this.API_BASE}/uploadSave?game=${encodeURIComponent(game)}`
|
const url = `${this.API_BASE}/uploadSave?game=${encodeURIComponent(game)}`
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
@@ -33,33 +35,40 @@ export class SaveParserBackendService {
|
|||||||
body: file,
|
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) {
|
if (!response.ok) {
|
||||||
throw new Error(
|
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()
|
return await response.json()
|
||||||
}
|
}
|
||||||
|
|
||||||
async getParsedSave(id: string): Promise<Record<string, any>> {
|
// 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, unknown>> {
|
||||||
const url = `${this.API_BASE}/getParsedSave?id=${encodeURIComponent(id)}`
|
const url = `${this.API_BASE}/getParsedSave?id=${encodeURIComponent(id)}`
|
||||||
const response = await fetch(url)
|
const response = await fetch(url)
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Failed to get parsed save: ${response.statusText} ${await this.tryGetResponseJson(response)}`
|
`Failed to get parsed save: ${response.statusText} ` +
|
||||||
|
(await getBodyTextOrEmpty(response))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Vendored
+6
@@ -0,0 +1,6 @@
|
|||||||
|
// Node's fetch needs `duplex: 'half'` for stream bodies,
|
||||||
|
// but lib.dom's RequestInit doesn't declare it.
|
||||||
|
// This interface is automatically merged with RequestInit from lib.dom.d.ts
|
||||||
|
interface RequestInit {
|
||||||
|
duplex?: 'half'
|
||||||
|
}
|
||||||
+30
-24
@@ -1,27 +1,33 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2017",
|
"target": "ES2017",
|
||||||
"lib": ["dom", "dom.iterable", "esnext"],
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"module": "esnext",
|
"module": "esnext",
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"jsx": "preserve",
|
"jsx": "react-jsx",
|
||||||
"incremental": true,
|
"incremental": true,
|
||||||
"plugins": [
|
"plugins": [
|
||||||
{
|
{
|
||||||
"name": "next"
|
"name": "next"
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
".next/dev/types/**/*.ts"
|
||||||
],
|
],
|
||||||
"paths": {
|
"exclude": ["node_modules"]
|
||||||
"@/*": ["./src/*"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
|
||||||
"exclude": ["node_modules"]
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user