Compare commits

..

3 Commits

Author SHA1 Message Date
Altair-sh 9c25922a3b created AGENTS.md 2026-09-14 00:22:26 +02:00
Altair-sh e9ede28098 fixed warnings from eslint 2026-09-13 23:50:59 +02:00
Altair-sh 656bc318d6 updated dependencies 2026-09-13 22:21:05 +02:00
16 changed files with 5759 additions and 357 deletions
+118
View File
@@ -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.
+9
View File
@@ -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,
])
+5500 -273
View File
File diff suppressed because it is too large Load Diff
+10 -5
View File
@@ -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
} }
} }
+13 -7
View File
@@ -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,
@@ -26,8 +28,7 @@ export async function redirectRequest(
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, {
@@ -41,9 +42,14 @@ export async function redirectRequest(
}) })
} }
/// 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. * Browsers send OPTIONS request sometimes to check webserver's allowed
export async function responseToOPTIONS( * 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 req: NextRequest
): Promise<NextResponse> { ): Promise<NextResponse> {
// console.log('proxy responding to CORS OPTIONS', req.url) // console.log('proxy responding to CORS OPTIONS', req.url)
@@ -1,9 +1,6 @@
import type { NextRequest } from 'next/server' import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { import { redirectRequest, respondToOPTIONS } from '@/app/api/proxy/functions'
redirectRequest,
responseToOPTIONS as 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'
+35 -26
View File
@@ -6,38 +6,47 @@ import {
themeQuartz, themeQuartz,
colorSchemeDark, colorSchemeDark,
} from 'ag-grid-community' } from 'ag-grid-community'
import { useState, useEffect } from 'react' 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
// Page that shows a save file's parsed data in a grid. // Page that shows a save file's parsed data in a grid.
export default function BrowseSaveDataPage() { export default function BrowseSaveDataPage() {
const [agTheme, setAgTheme] = useState(themeQuartz) // Read the color scheme straight from the browser instead of mirroring it
// into state, which would need an extra render to catch up.
// Keep the grid theme in sync with the browser's light/dark color scheme. const prefersDark = useSyncExternalStore(
useEffect(() => { subscribeToColorScheme,
const prefersDark = window.matchMedia( isDarkSchemePreferred,
'(prefers-color-scheme: dark)' isDarkSchemePreferredOnServer
).matches
setAgTheme(
prefersDark ? themeQuartz.withPart(colorSchemeDark) : themeQuartz
) )
const agTheme = prefersDark ? DARK_THEME : LIGHT_THEME
const listener = (e: MediaQueryListEvent) => {
setAgTheme(
e.matches ? themeQuartz.withPart(colorSchemeDark) : themeQuartz
)
}
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' }}>
+5 -3
View File
@@ -3,6 +3,7 @@
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. // Page with the login form.
export default function LoginPage() { export default function LoginPage() {
@@ -35,9 +36,10 @@ export default function LoginPage() {
formData.password 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)
} }
} }
+5 -3
View File
@@ -4,6 +4,7 @@ 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. // Page with the registration form.
export default function RegisterPage() { export default function RegisterPage() {
@@ -43,9 +44,10 @@ export default function RegisterPage() {
) )
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)
} }
} }
+10 -7
View File
@@ -7,6 +7,7 @@ import {
} from '@/services/SaveParserBackendService' } 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',
@@ -71,16 +72,18 @@ export default function SaveFileUploadingDialog() {
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)
} }
} }
@@ -145,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>
) )
+5
View File
@@ -4,5 +4,10 @@
export async function register() { export async function register() {
console.log('hello from instrumentation.ts!') console.log('hello from instrumentation.ts!')
await import('@/lib/customConsoleLog') // replaces console.log 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 await import('@/lib/configLoader') // forces loadConfig() to run at server startup
}
} }
+1 -1
View File
@@ -2,7 +2,7 @@
function injectConsoleLog() { function injectConsoleLog() {
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) originalLog(`[${timestamp}] ${message}`, ...optionalParams)
+7
View File
@@ -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 -1
View File
@@ -61,7 +61,7 @@ export class SaveParserBackendService {
} }
// Fetches the fully parsed data for a save once its parsing is done. // Fetches the fully parsed data for a save once its parsing is done.
async getParsedSave(id: string): Promise<Record<string, any>> { 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)
+6
View File
@@ -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'
}
+8 -2
View File
@@ -11,7 +11,7 @@
"moduleResolution": "bundler", "moduleResolution": "bundler",
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": true, "isolatedModules": true,
"jsx": "preserve", "jsx": "react-jsx",
"incremental": true, "incremental": true,
"plugins": [ "plugins": [
{ {
@@ -22,6 +22,12 @@
"@/*": ["./src/*"] "@/*": ["./src/*"]
} }
}, },
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], "include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": ["node_modules"] "exclude": ["node_modules"]
} }