Compare commits

..

6 Commits

Author SHA1 Message Date
Altair-sh
95880ca080 changed style of SaveFileUploadingDialog 2025-05-23 02:57:49 +05:00
Altair-sh
42adeb290c added some //TODO: 2025-05-23 02:04:15 +05:00
Altair-sh
8c270fc4ea fixed bugs 2025-05-23 01:51:50 +05:00
8e4ce66cc4 created proxy for parser backend 2025-05-22 23:31:17 +05:00
Altair-sh
9faf3f7618 created SaveFileUploadDialog and SaveParserBackendService 2025-05-22 17:09:44 +05:00
Altair-sh
c216250eca vscode launch.json 2025-05-22 15:57:04 +05:00
22 changed files with 397 additions and 26 deletions

1
.gitignore vendored
View File

@@ -32,6 +32,7 @@ yarn-error.log*
# env files (can opt-in for committing if needed)
.env*
/runtimeConfig.json
# vercel
.vercel

23
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,23 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "node-terminal: npm run dev",
"command": "npm run dev",
"request": "launch",
"skipFiles": ["<node_internals>/**"],
"type": "node-terminal"
},
{
"name": "node: npm run dev",
"request": "launch",
"runtimeArgs": ["run", "dev"],
"runtimeExecutable": "npm",
"skipFiles": ["<node_internals>/**"],
"type": "node"
}
]
}

16
package-lock.json generated
View File

@@ -15,7 +15,7 @@
"react-dom": "^19"
},
"devDependencies": {
"@types/node": "^20",
"@types/node": "^22",
"@types/react": "^19",
"@types/react-dom": "^19",
"prettier": "^3",
@@ -589,13 +589,13 @@
}
},
"node_modules/@types/node": {
"version": "20.17.50",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.50.tgz",
"integrity": "sha512-Mxiq0ULv/zo1OzOhwPqOA13I81CV/W3nvd3ChtQZRT5Cwz3cr0FKo/wMSsbTqL3EXpaBAEQhva2B8ByRkOIh9A==",
"version": "22.15.21",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.21.tgz",
"integrity": "sha512-EV/37Td6c+MgKAbkcLG6vqZ2zEYHD7bvSrzqqs2RIhbA6w3x+Dqz8MZM3sP6kGTeLrdoOgKZe+Xja7tUB2DNkQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.19.2"
"undici-types": "~6.21.0"
}
},
"node_modules/@types/react": {
@@ -1091,9 +1091,9 @@
}
},
"node_modules/undici-types": {
"version": "6.19.8",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz",
"integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==",
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
}

View File

@@ -17,7 +17,7 @@
"react-dom": "^19"
},
"devDependencies": {
"@types/node": "^20",
"@types/node": "^22",
"@types/react": "^19",
"@types/react-dom": "^19",
"prettier": "^3",

View File

@@ -0,0 +1,55 @@
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
/// Resend request to backend.
/// Body is being sent as stream.
export async function redirectRequest(
req: NextRequest,
proxyUrl: string,
backendBaseUrl: string
): Promise<NextResponse> {
const reqUrl = req.url // full URL e.g. http://localhost:3000/api/proxy?id=123
// Find position of proxyPath in reqUrl
const proxyIndex = reqUrl.indexOf(proxyUrl)
if (proxyIndex === -1) {
return new NextResponse('Invalid proxy path', { status: 400 })
}
// 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)
// console.log('redirecting', req.url, 'to', backendUrl)
const backendRes = await fetch(backendUrl, {
method: req.method,
headers: req.headers,
body: req.body,
// @ts-ignore
duplex: 'half', // some property required by nodejs but not defined in @types/node
})
return new NextResponse(backendRes.body, {
status: backendRes.status,
headers: {
'Access-Control-Allow-Origin': '*',
'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> {
// 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',
},
})
}

View File

@@ -0,0 +1,3 @@
export const PROXY_PATHS = {
saveParserBackend: '/api/proxy/saveParserBackend',
}

View File

@@ -0,0 +1,17 @@
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
import { redirectRequest, responseToOPTIONS as respondToOPTIONS } from '@/app/api/proxy/functions'
import { PROXY_PATHS } from '@/app/api/proxy/paths'
import { appConfig } from '@/lib/configLoader'
async function redirect(req: NextRequest): Promise<NextResponse> {
return await redirectRequest(req, PROXY_PATHS.saveParserBackend, appConfig.services.saveParserBackend.url)
}
export const GET = redirect
export const POST = redirect
export const PUT = redirect
export const PATCH = redirect
export const DELETE = redirect
export const HEAD = redirect
export const OPTIONS = respondToOPTIONS

View File

@@ -5,6 +5,8 @@ import { AllCommunityModule, themeQuartz } 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() {
const [agTheme, setAgTheme] = useState(themeQuartz)

View File

@@ -15,7 +15,6 @@ html,
body {
max-width: 100vw;
overflow-x: hidden;
padding: 10px;
}
body {

View File

@@ -1,7 +1,8 @@
'use client'
import { useState } from 'react'
import { authService } from '@/services/authService'
import { authService } from '@/services/AuthService'
import FormError from '@/components/FormError'
export default function LoginPage() {
const [formData, setFormData] = useState({
@@ -10,7 +11,7 @@ export default function LoginPage() {
stayLoggedIn: true,
})
const [error, setError] = useState('')
const [error, setError] = useState<string | null>(null)
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value, type, checked } = e.target
@@ -22,6 +23,7 @@ export default function LoginPage() {
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)
@@ -36,11 +38,7 @@ export default function LoginPage() {
<div className="container mt-5" style={{ maxWidth: '500px' }}>
<h2 className="mb-4">Login</h2>
{error && (
<div className="alert alert-danger" role="alert">
{error}
</div>
)}
<FormError message={error} />
<form onSubmit={handleSubmit}>
<div className="mb-3">

11
src/app/page.tsx Normal file
View File

@@ -0,0 +1,11 @@
import SaveFileUploadingDialog from '@/components/SaveFileUploadingDialog'
export default function HomePage() {
return (
<div className="container mt-5 text-center">
<h1 className="mb-4">Welcome to Paradox Save Parser!</h1>
<SaveFileUploadingDialog />
</div>
)
}

View File

@@ -2,7 +2,8 @@
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { authService } from '@/services/authService'
import { authService } from '@/services/AuthService'
import FormError from '@/components/FormError'
export default function RegisterPage() {
const router = useRouter()
@@ -14,7 +15,7 @@ export default function RegisterPage() {
confirmPassword: '',
})
const [error, setError] = useState('')
const [error, setError] = useState<string | null>(null)
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFormData((prev) => ({
@@ -25,6 +26,7 @@ export default function RegisterPage() {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError(null)
try {
if (formData.password !== formData.confirmPassword) {
throw new Error('Passwords do not match.')
@@ -33,7 +35,6 @@ export default function RegisterPage() {
console.log('Register', formData)
const result = await authService.register(formData.name, formData.email, formData.password)
console.log('Registered:', result)
router.push('/login') // Redirect after success
} catch (err: any) {
console.error(err.message)
@@ -45,11 +46,7 @@ export default function RegisterPage() {
<div className="container mt-5" style={{ maxWidth: '400px' }}>
<h2 className="mb-4 text-center">Register</h2>
{error && (
<div className="alert alert-danger" role="alert">
{error}
</div>
)}
<FormError message={error} />
<form onSubmit={handleSubmit}>
<div className="mb-3">

View File

@@ -0,0 +1,13 @@
interface FormErrorProps {
message: string | null
}
export default function FormError(props: FormErrorProps) {
if (!props.message || props.message === '') return null
return (
<div className="alert alert-danger" role="alert">
{props.message}
</div>
)
}

View File

@@ -1,7 +1,7 @@
'use client'
import Link from 'next/link'
import { font_Hack } from '@/utils/myFonts'
import { font_Hack } from '@/lib/myFonts'
export default function Navbar() {
return (

View File

@@ -0,0 +1,127 @@
'use client'
import { saveParserBackendService, SaveStatusResponse, UploadSaveResponse } from '@/services/SaveParserBackendService'
import { useState } from 'react'
import FormError from './FormError'
export enum GameEnum {
EU4 = 'eu4',
Stellaris = 'stellaris',
}
//TODO: move to separate page
//TODO: add redirect to ?id={id} to see status of long parsing save
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 handleGameSelect = (e: React.ChangeEvent<HTMLSelectElement>) => {
setGame(e.target.value as GameEnum)
}
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
setSelectedFile(e.target.files[0])
}
}
const handleUpload = async (e: React.FormEvent) => {
e.preventDefault()
setError(null)
setUploadDisabled(true)
try {
if (!selectedFile) return
console.log('Uploading file:', selectedFile.name)
const uploadResponse = await saveParserBackendService.uploadSave(game, selectedFile)
console.log('Upload response:', uploadResponse)
setUploadResult(uploadResponse)
const pollInterval = 1000 // ms
const intervalId = setInterval(async () => {
try {
const statusResponse = await saveParserBackendService.getSaveStatus(uploadResponse.id)
console.log('Save status:', statusResponse.status)
setSaveStatus(statusResponse)
if (statusResponse.status.toLowerCase() === 'done') {
clearInterval(intervalId)
setUploadDisabled(false)
}
} catch (err: any) {
console.error('Error while polling status:', err)
clearInterval(intervalId)
setError(err.message)
setUploadDisabled(false)
}
}, pollInterval)
} catch (err: any) {
console.error(err.message)
setError(err.message)
setUploadDisabled(false)
}
}
const gameEnumOptions = Object.values(GameEnum).map((value) => (
<option key={value} value={value}>
{value}
</option>
))
return (
<div className="container mt-5" style={{ maxWidth: '500px' }}>
<FormError message={error} />
<form onSubmit={handleUpload} className="text-start">
<label htmlFor="gameSelect" className="form-label">
Select Game
</label>
<select
id="gameSelect"
name="gameSelect"
className="form-select mb-3"
value={game}
onChange={handleGameSelect}
required
>
{gameEnumOptions}
</select>
<label htmlFor="fileInput" className="form-label">
Select File
</label>
<input id="fileInput" type="file" className="form-control mb-3" onChange={handleFileChange} />
<button
className="btn btn-primary w-100 mt-1 mb-3"
type="submit"
disabled={!selectedFile || uploadDisabled}
>
Upload File
</button>
</form>
<div className="container mt-5 text-center">
{/* TODO: extract this to a component
<ConditionalView cond={uploadResult}> <LabeledValue val={}/> </ConditionalView> */}
{uploadResult && (
<div className="mb-3">
Save Id: <strong>{uploadResult.id}</strong>
</div>
)}
{saveStatus && (
<div className="mb-3">
Status: <strong>{saveStatus.status}</strong>
</div>
)}
{/* add link to <ConditionalView cond={done}> /browse?id=${saveStatus.id} */}
</div>
</div>
)
}

12
src/config/AppConfig.ts Normal file
View File

@@ -0,0 +1,12 @@
'server'
export const defaultConfig = {
services: {
saveParserBackend: {
url: 'http://127.0.0.1:5226',
},
// TODO: implement auth api
auth: {},
},
}
export type AppConfig = typeof defaultConfig

5
src/instrumentation.ts Normal file
View File

@@ -0,0 +1,5 @@
import '@/lib/customConsoleLog'
export function register() {
console.log('hello from instrumentation.ts!')
}

23
src/lib/configLoader.ts Normal file
View File

@@ -0,0 +1,23 @@
'server'
import fs from 'fs'
import path from 'path'
import { AppConfig, defaultConfig } from '@/config/AppConfig'
const configFilePath = path.join(process.cwd(), 'runtimeConfig.json')
function loadConfig(): AppConfig {
let config = defaultConfig
if (fs.existsSync(configFilePath)) {
const raw = fs.readFileSync(configFilePath, 'utf-8')
const parsed = JSON.parse(raw)
config = { ...defaultConfig, ...parsed }
} else {
fs.mkdirSync(path.dirname(configFilePath), { recursive: true })
}
fs.writeFileSync(configFilePath, JSON.stringify(defaultConfig, null, 4))
return config
}
export const appConfig = loadConfig()

View File

@@ -0,0 +1,13 @@
let _custom_console_log_injected = false
if (!_custom_console_log_injected) {
_custom_console_log_injected = true
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')
}
console.log = customLog
}

View File

@@ -1,3 +1,5 @@
//TODO: controllable rate limits
class AuthService {
private API_BASE = '/api/auth'

View File

@@ -0,0 +1,70 @@
import { PROXY_PATHS } from '@/app/api/proxy/paths'
export interface UploadSaveResponse {
id: string
}
export interface SaveStatusResponse {
id: string
game: string
status: string
uploadDateTime: string
}
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
async uploadSave(game: string, file: File): Promise<UploadSaveResponse> {
const url = `${this.API_BASE}/uploadSave?game=${encodeURIComponent(game)}`
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/octet-stream',
},
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)}`
)
}
return await response.json()
}
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)}`
)
}
return await response.json()
}
}
export const saveParserBackendService = new SaveParserBackendService()