init
@@ -0,0 +1,182 @@
|
||||
# Agent Guidelines for LA2NodeJS
|
||||
|
||||
## Build/Lint/Test Commands
|
||||
|
||||
### Development
|
||||
```bash
|
||||
npm run dev # Start development server
|
||||
npm run build # Build production bundle
|
||||
```
|
||||
|
||||
### Linting & Formatting
|
||||
```bash
|
||||
npm run lint # Run linter (ESLint)
|
||||
npm run format # Format code (Prettier)
|
||||
npm run format:write # Format code in place
|
||||
```
|
||||
|
||||
### Testing
|
||||
```bash
|
||||
npm test # Run all tests
|
||||
npm test -- --watch # Run tests in watch mode
|
||||
npm test -- <pattern> # Run single test file or pattern
|
||||
npm test <test> # Run specific test (e.g., npm test -- src/__tests__/api.test.ts)
|
||||
```
|
||||
|
||||
### Single Test Execution
|
||||
To run a single test file:
|
||||
```bash
|
||||
npm test -- src/__tests__/utils.test.ts
|
||||
```
|
||||
|
||||
To run specific test cases with `test.only`:
|
||||
```bash
|
||||
npm run test:watch -- -t "describe name"
|
||||
```
|
||||
|
||||
To run individual test case using V8 flags (if Jest doesn't support `-t`):
|
||||
```bash
|
||||
npx jest src/__tests__/utils.test.ts -t "test description"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Code Style Guidelines
|
||||
|
||||
### Imports
|
||||
- Sort imports in this order: Node built-ins, third-party, relative
|
||||
- Group same-type imports together
|
||||
- Limit each import statement to a single type
|
||||
- Use `import type` for type-only imports
|
||||
- Maximum 8-10 imports per file
|
||||
- Use barrel exports only when reducing exports from a directory
|
||||
|
||||
### Formatting
|
||||
- Use Prettier with the configured `.prettierrc`
|
||||
- Line width: 100 characters
|
||||
- Tab indentation preferred
|
||||
- Single quotes for strings (if configured)
|
||||
- Semicolons included
|
||||
- Trailing commas in multi-line objects/arrays
|
||||
- No unnecessary parentheses (only for arrow functions and template literals)
|
||||
|
||||
### Type Safety
|
||||
- Enable strict TypeScript mode
|
||||
- Type all function parameters and return values
|
||||
- Use explicit types for async functions (`Promise<void>` or `Promise<T>`)
|
||||
- Avoid `any` type; use `unknown` for untrusted input
|
||||
- Use generic types in function signatures
|
||||
- Type union/literal types as narrow as possible
|
||||
- Avoid `@ts-ignore` / `@ts-expect-error` without comments
|
||||
|
||||
### Naming Conventions
|
||||
- Files: kebab-case (`example-file.ts`)
|
||||
- Components: PascalCase or kebab-case based on framework
|
||||
- Variables: camelCase for JS, snake_case if required by framework
|
||||
- Constants: UPPER_SNAKE_CASE
|
||||
- Classes: PascalCase
|
||||
- Functions: camelCase
|
||||
- Private members: `_private` prefix
|
||||
- Public methods with prefix `get|set|add|remove` for clarity
|
||||
- Event names: camelCase or kebab-case
|
||||
- Types/Interfaces: PascalCase
|
||||
|
||||
### Error Handling
|
||||
- Use `Error` subclassing for domain-specific errors
|
||||
- Throw errors when invariants are violated
|
||||
- Catch specific error types, not bare `catch`
|
||||
- Never swallow errors without logging or rethrowing
|
||||
- Use custom error types: `class NotFoundError extends Error { }`
|
||||
- Include context in error messages (path, id, etc.)
|
||||
- Avoid `process.env.NODE_ENV` checks; use runtime error detection
|
||||
- Use `.catch()` with async functions to handle Promise rejections
|
||||
|
||||
### Async/Await
|
||||
- Prefer `async/await` over `.then()` chains
|
||||
- Handle errors immediately after async calls
|
||||
- Return early from `async` functions on failure
|
||||
- Use `Promise.reject()` explicitly over `throw new Error()`
|
||||
- Avoid `.catch(() => void 0)`; instead, log or handle errors
|
||||
|
||||
### Code Organization
|
||||
- Place `types` declarations in `types/` directory
|
||||
- Utility functions in `utils/`
|
||||
- API/service modules in `services/` or `controllers/`
|
||||
- Tests in matching `__tests__/` or `.specs/` directories
|
||||
- Keep files under 400 lines
|
||||
- Maximum 10 files in any directory
|
||||
- Barrel files for logical grouping, not lazy imports
|
||||
|
||||
### Security
|
||||
- Never commit secrets to git
|
||||
- Validate all external inputs (URLs, IPs, email)
|
||||
- Use environment-specific config (not committed)
|
||||
- Sanitize data before logging
|
||||
- Validate API request payloads with Zod/schema validation
|
||||
- Avoid deprecated crypto functions (use Web Crypto API)
|
||||
- Use secure HTTP headers (CORS, Helmet if applicable)
|
||||
|
||||
### File Conventions
|
||||
- Use `.ts` over `.tsx` when possible
|
||||
- Export only named exports when multiple items exported
|
||||
- Index exports only for submodules/microservices
|
||||
- Use `.d.ts` for public type definitions
|
||||
- Keep test files alongside source files
|
||||
- Use `.env.local` for local overrides only
|
||||
|
||||
### Linting Rules
|
||||
- Disable ESLint rule only when necessary
|
||||
- Provide comment explaining why
|
||||
- Use `eslint-disable-next-line // reason` for single-line
|
||||
- Use `/* eslint-disable rule */` for blocks (max 5 lines)
|
||||
- Always re-enable rules after disabling
|
||||
|
||||
### Documentation
|
||||
- Document non-obvious code with JSDoc
|
||||
- Include @param and @returns for public APIs
|
||||
- Document complex algorithms with comments
|
||||
- Add TODO/FIXME comments for known issues
|
||||
- Link related issues in commit messages and PRs
|
||||
- Document breaking changes prominently
|
||||
|
||||
### Performance
|
||||
- Avoid unnecessary array method calls
|
||||
- Prefer `Object.entries()` over `Object.keys()` for iteration
|
||||
- Defer non-critical initializations
|
||||
- Cache expensive computations
|
||||
- Use `Set` for uniqueness checks
|
||||
- Batch DOM operations when applicable
|
||||
|
||||
### Testing Rules
|
||||
- Test one scenario per file
|
||||
- Arrange-Act-Assert pattern for tests
|
||||
- Mock external dependencies
|
||||
- Use `describe`/`it` for BDD-style tests
|
||||
- Order tests: Setup -> Happy Path -> Edge Cases -> Error Cases
|
||||
- Include coverage reports when debugging tests
|
||||
|
||||
---
|
||||
|
||||
## Migration Notes
|
||||
- Replace `.jsx` with `.tsx` where component doesn't use DOM APIs
|
||||
- Use `React.FC` only for strict component props (avoid for simplicity)
|
||||
- Use `useState`, `useReducer` for state with reset patterns
|
||||
- Use `useContext` for global state needs
|
||||
- Avoid `useEffect` for non-reactive state; keep side effects minimal
|
||||
|
||||
---
|
||||
|
||||
## Common Workflows
|
||||
|
||||
### Adding a new feature
|
||||
1. Create implementation file in appropriate directory
|
||||
2. Add types if needed in `types/`
|
||||
3. Add tests in matching `__tests__/` directory
|
||||
4. Run `npm run lint` and fix issues
|
||||
5. Create PR with descriptive title
|
||||
|
||||
### Debugging
|
||||
1. Add `console.log` with context
|
||||
2. Use `node -e` to run isolated snippets
|
||||
3. Check browser DevTools for React errors
|
||||
4. Use `breakpointMode: 'inspect-brk'` in package.json for debugging
|
||||
@@ -0,0 +1,25 @@
|
||||
FROM node:18-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install OpenSSL for Prisma
|
||||
RUN apt-get update && apt-get install -y openssl && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy package files
|
||||
COPY api/package.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm install
|
||||
|
||||
# Copy all source files (force fresh copy)
|
||||
COPY api/src ./src
|
||||
COPY api/prisma ./prisma
|
||||
|
||||
# Generate Prisma client
|
||||
RUN npx prisma generate
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3001
|
||||
|
||||
# Use tsx to run TypeScript directly
|
||||
CMD ["npx", "tsx", "src/index.ts"]
|
||||
@@ -0,0 +1,33 @@
|
||||
# Stage 1: Build React app
|
||||
FROM node:18-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY react-client/package.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm install
|
||||
|
||||
# Copy source code
|
||||
COPY react-client/src ./src
|
||||
COPY react-client/public ./public
|
||||
COPY react-client/vite.config.ts ./
|
||||
COPY react-client/tsconfig.json ./
|
||||
COPY react-client/index.html ./
|
||||
|
||||
# Build the app
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Production
|
||||
FROM nginx:alpine AS production
|
||||
|
||||
# Copy built files
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
# Copy nginx config
|
||||
COPY nginx/default.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -1,2 +1,395 @@
|
||||
# LA2NodeJS
|
||||
# LA2 Eternal - Deployment Guide
|
||||
|
||||
Complete step-by-step guide for deploying the LA2 Eternal Lineage 2 server portal on a fresh Ubuntu server.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Ubuntu 22.04 LTS or newer (Ubuntu 24.04 LTS recommended)
|
||||
- Root or sudo access
|
||||
- Minimum 2GB RAM, 10GB disk space
|
||||
- Internet connection
|
||||
|
||||
## Step 1: Update System
|
||||
|
||||
```bash
|
||||
sudo apt update && sudo apt upgrade -y
|
||||
```
|
||||
|
||||
## Step 2: Install Docker and Docker Compose
|
||||
|
||||
```bash
|
||||
# Remove any old Docker versions
|
||||
sudo apt remove -y docker docker-engine docker.io containerd runc
|
||||
|
||||
# Install prerequisites
|
||||
sudo apt install -y ca-certificates curl gnupg lsb-release
|
||||
|
||||
# Add Docker's official GPG key
|
||||
sudo mkdir -p /etc/apt/keyrings
|
||||
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
|
||||
|
||||
# Set up Docker repository
|
||||
echo \
|
||||
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \
|
||||
$(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
|
||||
|
||||
# Install Docker Engine
|
||||
sudo apt update
|
||||
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
|
||||
|
||||
# Verify installation
|
||||
sudo docker --version
|
||||
sudo docker compose version
|
||||
|
||||
# Add your user to docker group (optional, to run docker without sudo)
|
||||
sudo usermod -aG docker $USER
|
||||
newgrp docker
|
||||
```
|
||||
|
||||
## Step 3: Clone Project Repository
|
||||
|
||||
```bash
|
||||
# Create directory for the project
|
||||
mkdir -p /opt/la2eternal
|
||||
cd /opt/la2eternal
|
||||
|
||||
# Clone the repository (replace with your actual repository URL)
|
||||
git clone <your-repository-url> .
|
||||
|
||||
# Or if you have the project files locally, copy them
|
||||
cp -r /path/to/project/* /opt/la2eternal/
|
||||
```
|
||||
|
||||
## Step 4: Configure Environment Variables
|
||||
|
||||
```bash
|
||||
# Copy the example environment file
|
||||
cp .env .env.local
|
||||
|
||||
# Edit the environment file with your settings
|
||||
nano .env.local
|
||||
```
|
||||
|
||||
### Required Environment Variables:
|
||||
|
||||
```env
|
||||
# Portal Auth
|
||||
JWT_SECRET=your-super-secret-jwt-key-change-this
|
||||
JWT_EXPIRES_IN=8h
|
||||
|
||||
# Portal Database (SQLite - embedded, no external service needed)
|
||||
DATABASE_URL=file:./dev.db
|
||||
|
||||
# Game Server Database (MSSQL)
|
||||
GAME_SERVER_HOST=your-game-server-ip
|
||||
GAME_SERVER_PORT=1433
|
||||
GAME_SERVER_USER=sa
|
||||
GAME_SERVER_PASSWORD=your_game_server_password
|
||||
GAME_SERVER_DATABASE=lin2db
|
||||
|
||||
# Game World Database (MSSQL)
|
||||
GAME_WORLD_HOST=your-game-server-ip
|
||||
GAME_WORLD_PORT=1433
|
||||
GAME_WORLD_USER=sa
|
||||
GAME_WORLD_PASSWORD=your_game_server_password
|
||||
GAME_WORLD_DATABASE=lin2world
|
||||
|
||||
# Server Info
|
||||
SERVER_ID=1
|
||||
SERVER_NAME=LA2 Eternal
|
||||
SERVER_IP=your-server-public-ip
|
||||
SERVER_PORT=7777
|
||||
|
||||
# Frontend
|
||||
CORS_ORIGIN=http://localhost:5173
|
||||
API_PORT=3001
|
||||
REACT_PORT=5173
|
||||
NODE_ENV=production
|
||||
VITE_API_URL=http://your-server-ip:3001
|
||||
```
|
||||
|
||||
**Important:** Change all default passwords before deploying!
|
||||
|
||||
## Step 5: Build and Start Services
|
||||
|
||||
```bash
|
||||
# Build all Docker images
|
||||
sudo docker compose build
|
||||
|
||||
# Start all services in detached mode
|
||||
sudo docker compose up -d
|
||||
|
||||
# Check if all containers are running
|
||||
sudo docker compose ps
|
||||
```
|
||||
|
||||
You should see:
|
||||
- `la2_portal_api` - Node.js API server (with SQLite database)
|
||||
- `la2_portal_fe` - React frontend
|
||||
|
||||
## Step 6: Verify Deployment
|
||||
|
||||
```bash
|
||||
# Check API health
|
||||
curl http://localhost:3001/health
|
||||
|
||||
# Expected response:
|
||||
# {"status":"ok","timestamp":"..."}
|
||||
|
||||
# View logs if needed
|
||||
sudo docker compose logs -f api
|
||||
sudo docker compose logs -f react
|
||||
```
|
||||
|
||||
## Step 7: Access the Website
|
||||
|
||||
Open your browser and navigate to:
|
||||
- **Frontend**: http://your-server-ip:5173
|
||||
- **API**: http://your-server-ip:3001
|
||||
|
||||
## Step 8: Configure Firewall (Optional but Recommended)
|
||||
|
||||
```bash
|
||||
# Install UFW if not already installed
|
||||
sudo apt install -y ufw
|
||||
|
||||
# Allow SSH (don't lock yourself out!)
|
||||
sudo ufw allow 22/tcp
|
||||
|
||||
# Allow HTTP
|
||||
sudo ufw allow 80/tcp
|
||||
sudo ufw allow 5173/tcp
|
||||
|
||||
# Allow API
|
||||
sudo ufw allow 3001/tcp
|
||||
|
||||
# Enable firewall
|
||||
sudo ufw enable
|
||||
|
||||
# Check status
|
||||
sudo ufw status
|
||||
```
|
||||
|
||||
## Step 9: Setup Domain and SSL (Optional)
|
||||
|
||||
If you have a domain name and want to use HTTPS:
|
||||
|
||||
### Install Nginx
|
||||
|
||||
```bash
|
||||
sudo apt install -y nginx
|
||||
```
|
||||
|
||||
### Configure Nginx as Reverse Proxy
|
||||
|
||||
Create a new Nginx configuration:
|
||||
|
||||
```bash
|
||||
sudo nano /etc/nginx/sites-available/la2eternal
|
||||
```
|
||||
|
||||
Add the following configuration:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name your-domain.com www.your-domain.com;
|
||||
|
||||
# Frontend
|
||||
location / {
|
||||
proxy_pass http://localhost:5173;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
|
||||
# API
|
||||
location /api {
|
||||
proxy_pass http://localhost:3001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
|
||||
# Downloads
|
||||
location /downloads {
|
||||
alias /opt/la2eternal/public/downloads;
|
||||
autoindex off;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Enable the site:
|
||||
|
||||
```bash
|
||||
sudo ln -s /etc/nginx/sites-available/la2eternal /etc/nginx/sites-enabled/
|
||||
sudo nginx -t
|
||||
sudo systemctl restart nginx
|
||||
```
|
||||
|
||||
### Setup SSL with Let's Encrypt
|
||||
|
||||
```bash
|
||||
# Install Certbot
|
||||
sudo apt install -y certbot python3-certbot-nginx
|
||||
|
||||
# Obtain SSL certificate
|
||||
sudo certbot --nginx -d your-domain.com -d www.your-domain.com
|
||||
|
||||
# Test automatic renewal
|
||||
sudo certbot renew --dry-run
|
||||
```
|
||||
|
||||
## Step 10: Production Optimizations
|
||||
|
||||
### Enable Auto-Restart for Docker
|
||||
|
||||
```bash
|
||||
sudo systemctl enable docker
|
||||
```
|
||||
|
||||
### Create Systemd Service (Optional)
|
||||
|
||||
Create a systemd service for auto-start on boot:
|
||||
|
||||
```bash
|
||||
sudo nano /etc/systemd/system/la2eternal.service
|
||||
```
|
||||
|
||||
Add:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=LA2 Eternal Portal
|
||||
Requires=docker.service
|
||||
After=docker.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
RemainAfterExit=yes
|
||||
WorkingDirectory=/opt/la2eternal
|
||||
ExecStart=/usr/bin/docker compose up -d
|
||||
ExecStop=/usr/bin/docker compose down
|
||||
TimeoutStartSec=0
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
Enable the service:
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable la2eternal.service
|
||||
sudo systemctl start la2eternal.service
|
||||
```
|
||||
|
||||
## Maintenance Commands
|
||||
|
||||
```bash
|
||||
# View all running containers
|
||||
sudo docker compose ps
|
||||
|
||||
# View logs
|
||||
sudo docker compose logs -f
|
||||
|
||||
# Restart services
|
||||
sudo docker compose restart
|
||||
|
||||
# Stop all services
|
||||
sudo docker compose down
|
||||
|
||||
# Stop and remove all containers and volumes (WARNING: deletes database data)
|
||||
sudo docker compose down -v
|
||||
|
||||
# Update and rebuild
|
||||
sudo docker compose pull
|
||||
sudo docker compose build
|
||||
sudo docker compose up -d
|
||||
|
||||
# Backup SQLite database (from API container)
|
||||
sudo docker exec la2_portal_api sh -c 'cp /app/dev.db /app/dev.db.backup.$(date +%Y%m%d)'
|
||||
sudo docker cp la2_portal_api:/app/dev.db.backup.$(date +%Y%m%d) ./backup-$(date +%Y%m%d).db
|
||||
|
||||
# Restore SQLite database (to API container)
|
||||
sudo docker cp ./backup-date.db la2_portal_api:/app/dev.db
|
||||
sudo docker restart la2_portal_api
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Container won't start
|
||||
|
||||
```bash
|
||||
# Check logs
|
||||
sudo docker compose logs [service-name]
|
||||
|
||||
# Check for port conflicts
|
||||
sudo netstat -tulpn | grep 3001
|
||||
sudo netstat -tulpn | grep 5173
|
||||
|
||||
# Restart specific service
|
||||
sudo docker compose restart api
|
||||
```
|
||||
|
||||
### Database connection issues
|
||||
|
||||
```bash
|
||||
# Check if SQLite database file exists
|
||||
cd /home/user/Documents/LA2NodeJS/api && ls -la dev.db
|
||||
|
||||
# View API logs for database errors
|
||||
sudo docker compose logs -f api
|
||||
|
||||
# Reset database (WARNING: deletes all data)
|
||||
rm -f /home/user/Documents/LA2NodeJS/api/dev.db
|
||||
# Restart API to recreate database with Prisma migrations
|
||||
sudo docker compose restart api
|
||||
```
|
||||
|
||||
### Permission denied errors
|
||||
|
||||
```bash
|
||||
# Fix Docker socket permissions
|
||||
sudo chmod 666 /var/run/docker.sock
|
||||
|
||||
# Or add user to docker group
|
||||
sudo usermod -aG docker $USER
|
||||
newgrp docker
|
||||
```
|
||||
|
||||
### Out of disk space
|
||||
|
||||
```bash
|
||||
# Clean up unused Docker images and containers
|
||||
sudo docker system prune -a
|
||||
|
||||
# Check disk usage
|
||||
sudo docker system df
|
||||
```
|
||||
|
||||
## Security Recommendations
|
||||
|
||||
1. **Change all default passwords** in `.env` file
|
||||
2. **Use strong JWT_SECRET** (at least 32 random characters)
|
||||
3. **Enable UFW firewall** and only open necessary ports
|
||||
4. **Keep system updated** with `sudo apt update && sudo apt upgrade`
|
||||
5. **Use SSL/HTTPS** for production deployments
|
||||
6. **Regular backups** of the database
|
||||
7. **Monitor logs** for suspicious activity
|
||||
|
||||
## Support
|
||||
|
||||
For issues or questions:
|
||||
- Check the logs: `sudo docker compose logs -f`
|
||||
- Verify environment variables in `.env` file
|
||||
- Ensure game server database is accessible from the web server
|
||||
- Check firewall rules if services are not accessible
|
||||
|
||||
## License
|
||||
|
||||
This project is for private use only. All Lineage 2 assets and trademarks belong to NCSoft.
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
drop database lin2clancomm
|
||||
drop database lin2comm
|
||||
drop database lin2db
|
||||
drop database lin2log
|
||||
drop database lin2report
|
||||
drop database lin2user
|
||||
drop database lin2world
|
||||
drop database petition
|
||||
go
|
||||
|
||||
create database lin2clancomm
|
||||
create database lin2comm
|
||||
create database lin2db
|
||||
create database lin2log
|
||||
create database lin2report
|
||||
create database lin2user
|
||||
create database lin2world
|
||||
create database petition
|
||||
go
|
||||
@@ -0,0 +1,649 @@
|
||||
USE [lin2log]
|
||||
GO
|
||||
|
||||
CREATE TABLE [dbo].[log_insert] (
|
||||
[log_file] nvarchar(255) COLLATE Korean_Wansung_CI_AS NOT NULL,
|
||||
[log_table] nvarchar(50) COLLATE Korean_Wansung_CI_AS NOT NULL,
|
||||
[rowsprocessed] int NOT NULL,
|
||||
[log_year] int NOT NULL,
|
||||
[log_month] int NOT NULL,
|
||||
[log_day] int NOT NULL,
|
||||
[log_hour] int NOT NULL,
|
||||
[log_ip] int NOT NULL,
|
||||
[log_svr] nvarchar(20) COLLATE Korean_Wansung_CI_AS NOT NULL,
|
||||
[log_inout] nvarchar(20) COLLATE Korean_Wansung_CI_AS NOT NULL,
|
||||
[process_time] int NULL,
|
||||
[inserted] int NULL
|
||||
)
|
||||
ON [PRIMARY]
|
||||
GO
|
||||
|
||||
CREATE PROCEDURE [dbo].[lin_BulkInsert]
|
||||
|
||||
AS
|
||||
SELECT 0
|
||||
GO
|
||||
|
||||
CREATE PROCEDURE [dbo].[lin_CheckLogTimeTable2]
|
||||
|
||||
AS
|
||||
SELECT 0
|
||||
GO
|
||||
|
||||
CREATE PROCEDURE [dbo].[lin_DropLogTable]
|
||||
|
||||
AS
|
||||
SELECT 0
|
||||
GO
|
||||
|
||||
CREATE PROCEDURE [dbo].[lin_GetWorldSnap]
|
||||
|
||||
AS
|
||||
SELECT 0
|
||||
GO
|
||||
|
||||
CREATE PROCEDURE [dbo].[lin_MakeChatLogTable]
|
||||
|
||||
AS
|
||||
SELECT 0
|
||||
GO
|
||||
|
||||
CREATE PROCEDURE [dbo].[lin_MakeItemLogTable]
|
||||
|
||||
AS
|
||||
SELECT 0
|
||||
GO
|
||||
|
||||
CREATE PROCEDURE [dbo].[lin_MakeLogTable]
|
||||
|
||||
AS
|
||||
SELECT 0
|
||||
GO
|
||||
|
||||
CREATE PROCEDURE [dbo].[lin_SetInserted]
|
||||
|
||||
AS
|
||||
SELECT 0
|
||||
GO
|
||||
|
||||
ALTER PROCEDURE dbo.lin_BulkInsert
|
||||
(
|
||||
@log_table varchar(512),
|
||||
@log_file varchar(128)
|
||||
)
|
||||
AS
|
||||
|
||||
set nocount on
|
||||
|
||||
declare @sql varchar(1024)
|
||||
|
||||
set @sql = ' set nocount on'
|
||||
+ ' BULK INSERT ' + @log_table + ' FROM ''' + @log_file + ''' WITH ( MAXERRORS = 65535, FIELDTERMINATOR = '','', ROWTERMINATOR = ''\n'' ) '
|
||||
+ ' select @@ROWCOUNT '
|
||||
exec ( @sql )
|
||||
GO
|
||||
|
||||
/********************************************
|
||||
lin_CheckLogTimeTable2
|
||||
do check whether real time log table is exist or not
|
||||
INPUT
|
||||
@strDate varchar(16),
|
||||
@nWorld int
|
||||
OUTPUT
|
||||
|
||||
return
|
||||
made by
|
||||
young
|
||||
date
|
||||
2002-11-11
|
||||
********************************************/
|
||||
ALTER PROCEDURE [DBO].[lin_CheckLogTimeTable2]
|
||||
(
|
||||
@strDate varchar(16),
|
||||
@nWorld int
|
||||
)
|
||||
AS
|
||||
SET NOCOUNT ON
|
||||
|
||||
declare @table_name varchar(60)
|
||||
declare @table2_name varchar(60)
|
||||
declare @view_name varchar(60)
|
||||
declare @sql varchar(2048)
|
||||
|
||||
-- check log_realtime
|
||||
set @table_name = @strDate + 'log_realtime_' + cast (@nWorld as varchar)
|
||||
set @sql = 'select * from sysobjects (nolock) where name = '''+ @table_name + ''''
|
||||
|
||||
exec ( @sql)
|
||||
if ( @@ROWCOUNT = 0)
|
||||
begin
|
||||
set @sql = 'exec lin_MakeLogTable ''' + @table_name + ''''
|
||||
exec (@sql)
|
||||
end
|
||||
|
||||
-- check log_audit
|
||||
set @table_name = @strDate + 'log_audit_' + cast (@nWorld as varchar)
|
||||
set @sql = 'select * from sysobjects (nolock) where name = '''+ @table_name + ''''
|
||||
exec ( @sql)
|
||||
if ( @@ROWCOUNT = 0)
|
||||
begin
|
||||
set @sql = 'exec lin_MakeLogTable ''' + @table_name + ''''
|
||||
exec (@sql)
|
||||
end
|
||||
|
||||
-- check log_data ( store 0~12 hour log)
|
||||
set @table_name = @strDate + 'log_data_' + cast (@nWorld as varchar)
|
||||
set @sql = 'select * from sysobjects (nolock) where name = '''+ @table_name + ''''
|
||||
exec ( @sql)
|
||||
if ( @@ROWCOUNT = 0)
|
||||
begin
|
||||
set @sql = 'exec lin_MakeLogTable ''' + @table_name + ''''
|
||||
exec (@sql)
|
||||
end
|
||||
|
||||
-- check log_data2 ( store 12~24 hour log)
|
||||
set @table2_name = @strDate + 'log_data2_' + cast (@nWorld as varchar)
|
||||
set @sql = 'select * from sysobjects (nolock) where name = '''+ @table2_name + ''''
|
||||
exec ( @sql)
|
||||
if ( @@ROWCOUNT = 0)
|
||||
begin
|
||||
set @sql = 'exec lin_MakeLogTable ''' + @table2_name + ''''
|
||||
exec (@sql)
|
||||
end
|
||||
|
||||
-- check log_data0 ( view )
|
||||
set @view_name = @strDate + 'log_data0_' + cast (@nWorld as varchar)
|
||||
set @sql = 'select * from sysobjects (nolock) where name = '''+ @view_name + ''''
|
||||
exec ( @sql)
|
||||
if ( @@ROWCOUNT = 0)
|
||||
begin
|
||||
-- set @sql = 'exec lin_MakeLogTable ''' + @table_name + ''''
|
||||
set @sql = 'CREATE VIEW dbo.' + @view_name + ' ( '
|
||||
+ ' act_time, log_id, actor, actor_account, target, target_account, location_x, location_y, location_z, '
|
||||
+ 'etc_str1, etc_str2, etc_str3, '
|
||||
+ 'etc_num1, etc_num2, etc_num3, etc_num4, etc_num5, etc_num6, etc_num7, etc_num8, etc_num9, etc_num10, '
|
||||
+ 'STR_actor, STR_actor_account, STR_target, STR_target_account, item_id '
|
||||
+ ' ) AS '
|
||||
+ ' SELECT act_time, log_id, actor, actor_account, target, target_account, location_x, location_y, location_z, '
|
||||
+ ' etc_str1, etc_str2, etc_str3, '
|
||||
+ ' etc_num1, etc_num2, etc_num3, etc_num4, etc_num5, etc_num6, etc_num7, etc_num8, etc_num9, etc_num10, '
|
||||
+ ' STR_actor, STR_actor_account, STR_target, STR_target_account, item_id '
|
||||
+ ' from ' + @table_name + ' (nolock) UNION '
|
||||
+ ' SELECT act_time, log_id, actor, actor_account, target, target_account, location_x, location_y, location_z, '
|
||||
+ ' etc_str1, etc_str2, etc_str3, '
|
||||
+ ' etc_num1, etc_num2, etc_num3, etc_num4, etc_num5, etc_num6, etc_num7, etc_num8, etc_num9, etc_num10, '
|
||||
+ ' STR_actor, STR_actor_account, STR_target, STR_target_account, item_id '
|
||||
+ ' from ' + @table2_name + ' (nolock) '
|
||||
exec (@sql)
|
||||
end
|
||||
|
||||
-- check chat
|
||||
set @table_name = @strDate + 'log_chat_' + cast (@nWorld as varchar)
|
||||
set @sql = 'select * from sysobjects (nolock) where name = '''+ @table_name + ''''
|
||||
exec ( @sql)
|
||||
if ( @@ROWCOUNT = 0)
|
||||
begin
|
||||
set @sql = 'exec lin_MakeChatLogTable ''' + @table_name + ''''
|
||||
exec (@sql)
|
||||
end
|
||||
GO
|
||||
|
||||
ALTER PROCEDURE dbo.lin_DropLogTable
|
||||
@drop_date datetime,
|
||||
@drop_world int
|
||||
AS
|
||||
|
||||
if @drop_date is null
|
||||
begin
|
||||
set @drop_date = getdate()
|
||||
set @drop_date = dateadd(d, -4, getdate())
|
||||
end
|
||||
|
||||
DECLARE @nyear int
|
||||
DECLARE @nmonth int
|
||||
DECLARE @nday int
|
||||
DECLARE @stryear varchar(10)
|
||||
DECLARE @strmonth varchar(10)
|
||||
DECLARE @strday varchar(10)
|
||||
DECLARE @str_report varchar(32)
|
||||
DECLARE @logdate int
|
||||
|
||||
set @nyear = datepart(yyyy, @drop_date)
|
||||
set @nmonth = datepart(mm, @drop_date)
|
||||
set @nday = datepart(dd, @drop_date)
|
||||
|
||||
set @stryear = cast(@nyear as varchar)
|
||||
if @nmonth < 10
|
||||
set @strmonth = '0' + cast(@nmonth as varchar)
|
||||
else
|
||||
set @strmonth = cast (@nmonth as varchar)
|
||||
|
||||
if @nday < 10
|
||||
set @strday = '0' + cast(@nday as varchar)
|
||||
else
|
||||
set @strday = cast (@nday as varchar)
|
||||
|
||||
set @str_report = @stryear + '/' + @strmonth + '/' + @strday
|
||||
set @logdate = cast(@stryear + @strmonth + @strday as int)
|
||||
|
||||
------------- now.. we have year, month, day string
|
||||
DECLARE @table_from varchar(60)
|
||||
declare @sql varchar (1024)
|
||||
|
||||
|
||||
set @table_from = 'L' + @stryear + '_' + @strmonth + '_' + @strday + '_log_data0_' + cast ( @drop_world as varchar)
|
||||
set @sql = ' drop view ' + @table_from
|
||||
|
||||
exec (@sql)
|
||||
|
||||
|
||||
set @table_from = 'L' + @stryear + '_' + @strmonth + '_' + @strday + '_log_data_' + cast ( @drop_world as varchar)
|
||||
set @sql = ' drop table ' + @table_from
|
||||
|
||||
exec (@sql)
|
||||
|
||||
|
||||
set @table_from = 'L' + @stryear + '_' + @strmonth + '_' + @strday + '_log_data2_' + cast ( @drop_world as varchar)
|
||||
set @sql = ' drop table ' + @table_from
|
||||
|
||||
exec (@sql)
|
||||
|
||||
/*
|
||||
set @table_from = 'L' + @stryear + '_' + @strmonth + '_' + @strday + '_log_chat_' + cast ( @drop_world as varchar)
|
||||
set @sql = ' drop table ' + @table_from
|
||||
|
||||
exec (@sql)
|
||||
*/
|
||||
GO
|
||||
|
||||
/******************************************************************************
|
||||
#Name: lin_GetWorldSnap
|
||||
#Desc: do make snap shot table of user_data, user_item, pledge, user_nobless
|
||||
|
||||
#Argument:
|
||||
Input: @db_server varchar(30) server name
|
||||
@user_id varchar(30) login id
|
||||
@user_pass varchar(30) password
|
||||
@world_id int world id
|
||||
@dtnow varchar(8) yyyyMMdd
|
||||
Output:
|
||||
#Return:
|
||||
#Result Set:
|
||||
|
||||
#Remark:
|
||||
#Example: exec lin_GetWorldSnap 'l2db2', 'gamma', '********', 8
|
||||
#See:
|
||||
|
||||
#History:
|
||||
Create flagoftiger 2004-06-14
|
||||
Modify btwinuni 2005-05-16 add: pledge
|
||||
Modify btwinuni 2005-09-29 add: user_nobless
|
||||
Modify btwinuni 2005-10-27 add parameter: dtnow
|
||||
******************************************************************************/
|
||||
ALTER PROCEDURE [DBO].[lin_GetWorldSnap]
|
||||
@db_server varchar(30),
|
||||
@user_id varchar(30),
|
||||
@user_pass varchar(30),
|
||||
@world_id int,
|
||||
@dtnow varchar(8) = ''
|
||||
AS
|
||||
SET NOCOUNT ON
|
||||
SET ANSI_WARNINGS ON
|
||||
SET ANSI_NULLS ON
|
||||
|
||||
declare @tmp_user_item nvarchar(50)
|
||||
declare @tmp_user_data nvarchar(50)
|
||||
declare @tmp_pledge nvarchar(50)
|
||||
declare @tmp_user_nobless nvarchar(50)
|
||||
declare @drop_user_item nvarchar(50)
|
||||
declare @drop_user_data nvarchar(50)
|
||||
declare @drop_pledge nvarchar(50)
|
||||
declare @drop_user_nobless nvarchar(50)
|
||||
declare @dtnow2 nvarchar(8)
|
||||
declare @sql varchar(4000)
|
||||
|
||||
if @dtnow = ''
|
||||
begin
|
||||
set @dtnow = convert(varchar, getdate(), 112)
|
||||
end
|
||||
|
||||
set @dtnow2 = convert(varchar, DATEADD(d, -2, cast(@dtnow as datetime)), 112)
|
||||
|
||||
-- set table name
|
||||
set @tmp_user_item = 'S' + left(@dtnow,4) + '_' + substring(@dtnow,5,2) + '_' + right(@dtnow,2) + '_snap_item_' + cast(@world_id as varchar)
|
||||
set @tmp_user_data = 'S' + left(@dtnow,4) + '_' + substring(@dtnow,5,2) + '_' + right(@dtnow,2) + '_snap_data_' + cast(@world_id as varchar)
|
||||
set @tmp_pledge = 'S' + left(@dtnow,4) + '_' + substring(@dtnow,5,2) + '_' + right(@dtnow,2) + '_snap_pledge_' + cast(@world_id as varchar)
|
||||
set @tmp_user_nobless = 'S' + left(@dtnow,4) + '_' + substring(@dtnow,5,2) + '_' + right(@dtnow,2) + '_snap_nobless_' + cast(@world_id as varchar)
|
||||
|
||||
-- set drop table name
|
||||
set @drop_user_item = 'S' + left(@dtnow2,4) + '_' + substring(@dtnow2,5,2) + '_' + right(@dtnow2,2) + '_snap_item_' + cast(@world_id as varchar)
|
||||
set @drop_user_data = 'S' + left(@dtnow2,4) + '_' + substring(@dtnow2,5,2) + '_' + right(@dtnow2,2) + '_snap_data_' + cast(@world_id as varchar)
|
||||
set @drop_pledge = 'S' + left(@dtnow2,4) + '_' + substring(@dtnow2,5,2) + '_' + right(@dtnow2,2) + '_snap_pledge_' + cast(@world_id as varchar)
|
||||
set @drop_user_nobless = 'S' + left(@dtnow2,4) + '_' + substring(@dtnow2,5,2) + '_' + right(@dtnow2,2) + '_snap_nobless_' + cast(@world_id as varchar)
|
||||
|
||||
|
||||
--------------------------------------------------------------------------------------------------------------------------------------------
|
||||
-- user_item snap shot
|
||||
--------------------------------------------------------------------------------------------------------------------------------------------
|
||||
|
||||
-- check table whether @drop_user_item is exists or not
|
||||
set @sql = 'if exists (select * from dbo.sysobjects where id = object_id(N''[dbo].[' + @drop_user_item + ']'') and objectproperty(id, N''IsUserTable'') = 1)'
|
||||
+ ' begin'
|
||||
+ ' drop table dbo.' + @drop_user_item
|
||||
+ ' end'
|
||||
exec (@sql)
|
||||
|
||||
-- check table whether @tmp_user_item is exists or not
|
||||
set @sql = 'if exists (select * from dbo.sysobjects where id = object_id(N''[dbo].[' + @tmp_user_item + ']'') and objectproperty(id, N''IsUserTable'') = 1)'
|
||||
+ ' begin'
|
||||
+ ' drop table dbo.' + @tmp_user_item
|
||||
+ ' end'
|
||||
exec (@sql)
|
||||
|
||||
|
||||
set @sql = ' select * into dbo.' + @tmp_user_item
|
||||
+ ' from OPENROWSET ( ''SQLOLEDB'', ''' + @db_server + ''';''' + @user_id + ''';''' + @user_pass + ''', ''select * from lin2world.dbo.tmp_user_item (nolock) where char_id > 0 '') '
|
||||
exec (@sql )
|
||||
|
||||
set @sql = 'CREATE CLUSTERED INDEX IX_' + @tmp_user_item + '_1 on dbo.' + @tmp_user_item + ' (char_id asc, item_type asc, enchant desc ) with fillfactor = 90 '
|
||||
exec (@sql)
|
||||
set @sql = 'CREATE NONCLUSTERED INDEX IX_' + @tmp_user_item + '_2 on dbo.' + @tmp_user_item + ' (item_type asc, enchant desc ) with fillfactor = 90 '
|
||||
exec (@sql)
|
||||
|
||||
|
||||
|
||||
--------------------------------------------------------------------------------------------------------------------------------------------
|
||||
-- user_data snap shot
|
||||
--------------------------------------------------------------------------------------------------------------------------------------------
|
||||
|
||||
-- check table whether @drop_user_data is exists or not
|
||||
set @sql = 'if exists (select * from dbo.sysobjects where id = object_id(N''[dbo].[' + @drop_user_data + ']'') and objectproperty(id, N''IsUserTable'') = 1)'
|
||||
+ ' begin'
|
||||
+ ' drop table dbo.' + @drop_user_data
|
||||
+ ' end'
|
||||
exec (@sql)
|
||||
|
||||
-- check table whether 'tmp_user_data' is exists or not
|
||||
set @sql = 'if exists (select * from dbo.sysobjects where id = object_id(N''[dbo].[' + @tmp_user_data + ']'') and objectproperty(id, N''IsUserTable'') = 1)'
|
||||
+ ' begin'
|
||||
+ ' drop table dbo.' + @tmp_user_data
|
||||
+ ' end'
|
||||
exec (@sql)
|
||||
|
||||
|
||||
set @sql = ' select * into dbo.' + @tmp_user_data
|
||||
+ ' from OPENROWSET ( ''SQLOLEDB'', ''' + @db_server + ''';''' + @user_id + ''';''' + @user_pass + ''', ''select * from lin2world.dbo.tmp_user_data (nolock)'') '
|
||||
exec (@sql )
|
||||
|
||||
set @sql = 'CREATE CLUSTERED INDEX IX_' + @tmp_user_data + '_1 on dbo.' + @tmp_user_data + ' (exp desc) with fillfactor = 90 '
|
||||
exec (@sql )
|
||||
set @sql = 'CREATE NONCLUSTERED INDEX IX_' + @tmp_user_data + '_2 on dbo.' + @tmp_user_data + ' (race asc, exp desc) with fillfactor = 90 '
|
||||
exec (@sql )
|
||||
set @sql = 'CREATE NONCLUSTERED INDEX IX_' + @tmp_user_data + '_3 on dbo.' + @tmp_user_data + ' (class asc, exp desc) with fillfactor = 90 '
|
||||
exec (@sql )
|
||||
|
||||
|
||||
|
||||
--------------------------------------------------------------------------------------------------------------------------------------------
|
||||
-- pledge snap shot
|
||||
--------------------------------------------------------------------------------------------------------------------------------------------
|
||||
|
||||
-- check table whether @drop_pledge is exists or not
|
||||
set @sql = 'if exists (select * from dbo.sysobjects where id = object_id(N''[dbo].[' + @drop_pledge + ']'') and objectproperty(id, N''IsUserTable'') = 1)'
|
||||
+ ' begin'
|
||||
+ ' drop table dbo.' + @drop_pledge
|
||||
+ ' end'
|
||||
exec (@sql)
|
||||
|
||||
-- check table whether '@tmp_pledge' is exists or not
|
||||
set @sql = 'if exists (select * from dbo.sysobjects where id = object_id(N''[dbo].[' + @tmp_pledge + ']'') and objectproperty(id, N''IsUserTable'') = 1)'
|
||||
+ ' begin'
|
||||
+ ' drop table dbo.' + @tmp_pledge
|
||||
+ ' end'
|
||||
exec (@sql)
|
||||
|
||||
|
||||
set @sql = ' select * into dbo.' + @tmp_pledge
|
||||
+ ' from OPENROWSET ( ''SQLOLEDB'', ''' + @db_server + ''';''' + @user_id + ''';''' + @user_pass + ''', ''select * from lin2world.dbo.tmp_pledge (nolock)'') '
|
||||
exec (@sql )
|
||||
|
||||
set @sql = 'CREATE CLUSTERED INDEX IX_' + @tmp_pledge + '_1 on dbo.' + @tmp_pledge + ' (pledge_id) with fillfactor = 90 '
|
||||
exec (@sql )
|
||||
|
||||
|
||||
--------------------------------------------------------------------------------------------------------------------------------------------
|
||||
-- user_nobless snap shot
|
||||
--------------------------------------------------------------------------------------------------------------------------------------------
|
||||
|
||||
-- check table whether @drop_user_nobless is exists or not
|
||||
set @sql = 'if exists (select * from dbo.sysobjects where id = object_id(N''[dbo].[' + @drop_user_nobless + ']'') and objectproperty(id, N''IsUserTable'') = 1)'
|
||||
+ ' begin'
|
||||
+ ' drop table dbo.' + @drop_user_nobless
|
||||
+ ' end'
|
||||
exec (@sql)
|
||||
|
||||
-- check table whether '@tmp_user_nobless' is exists or not
|
||||
set @sql = 'if exists (select * from dbo.sysobjects where id = object_id(N''[dbo].[' + @tmp_user_nobless + ']'') and objectproperty(id, N''IsUserTable'') = 1)'
|
||||
+ ' begin'
|
||||
+ ' drop table dbo.' + @tmp_user_nobless
|
||||
+ ' end'
|
||||
exec (@sql)
|
||||
|
||||
|
||||
set @sql = ' select * into dbo.' + @tmp_user_nobless
|
||||
+ ' from OPENROWSET ( ''SQLOLEDB'', ''' + @db_server + ''';''' + @user_id + ''';''' + @user_pass + ''', ''select * from lin2world.dbo.tmp_user_nobless (nolock)'') '
|
||||
exec (@sql )
|
||||
|
||||
set @sql = 'CREATE CLUSTERED INDEX IX_' + @tmp_user_nobless + '_1 on dbo.' + @tmp_user_nobless + ' (char_id) with fillfactor = 90 '
|
||||
exec (@sql )
|
||||
GO
|
||||
|
||||
/********************************************
|
||||
lin_MakeChatLogTable
|
||||
do make whether log table is exist or not
|
||||
INPUT
|
||||
@table_name varchar(60)
|
||||
OUTPUT
|
||||
|
||||
return
|
||||
made by
|
||||
young
|
||||
date
|
||||
2003-09-19
|
||||
********************************************/
|
||||
ALTER PROCEDURE [DBO].[lin_MakeChatLogTable]
|
||||
(
|
||||
@table_name varchar(60)
|
||||
)
|
||||
AS
|
||||
SET NOCOUNT ON
|
||||
|
||||
declare @sql varchar(1024)
|
||||
|
||||
set @sql = 'CREATE TABLE dbo.' + @table_name + ' ('
|
||||
+ 'act_time datetime NULL ,'
|
||||
+ 'log_id smallint NULL ,'
|
||||
+ 'actor int NULL , '
|
||||
+ 'target int NULL , '
|
||||
+ 'location_x int NULL , '
|
||||
+ 'location_y int NULL , '
|
||||
+ 'location_z int NULL , '
|
||||
+ 'say varchar (256) NULL , '
|
||||
+ 'STR_actor varchar (32) NULL , '
|
||||
+ 'STR_target varchar (32) NULL '
|
||||
+ ' )'
|
||||
|
||||
exec (@sql)
|
||||
|
||||
set @sql = 'CREATE CLUSTERED INDEX IX_' + @table_name + '_ACTOR on dbo.' + @table_name + ' (log_id, actor ) '
|
||||
exec (@sql)
|
||||
|
||||
set @sql = 'CREATE NONCLUSTERED INDEX IX_' + @table_name + '_TARGET on dbo.' + @table_name + ' ( log_id, target ) '
|
||||
exec (@sql)
|
||||
GO
|
||||
|
||||
/********************************************
|
||||
lin_MakeItemLogTable
|
||||
do make whether log table is exist or not
|
||||
INPUT
|
||||
@table_name varchar(60)
|
||||
OUTPUT
|
||||
|
||||
return
|
||||
made by
|
||||
young
|
||||
date
|
||||
2002-11-11
|
||||
********************************************/
|
||||
ALTER PROCEDURE [DBO].[lin_MakeItemLogTable]
|
||||
(
|
||||
@table_name varchar(60)
|
||||
)
|
||||
AS
|
||||
SET NOCOUNT ON
|
||||
|
||||
declare @sql varchar(1024)
|
||||
|
||||
set @sql = 'CREATE TABLE dbo.' + @table_name + ' ('
|
||||
+ 'act_time datetime NULL ,'
|
||||
+ 'log_id smallint NULL ,'
|
||||
+ 'item_id int NULL , '
|
||||
+ 'item_type int NULL , '
|
||||
+ 'actor varchar(32) NULL , '
|
||||
+ 'actor_account varchar(32) NULL , '
|
||||
+ 'target varchar(32) NULL , '
|
||||
+ 'target_account varchar(32) NULL , '
|
||||
+ 'location_x int NULL , '
|
||||
+ 'location_y int NULL , '
|
||||
+ 'location_z int NULL , '
|
||||
+ 'etc_num1 int NULL , '
|
||||
+ 'etc_num2 int NULL , '
|
||||
+ 'etc_num3 int NULL , '
|
||||
+ 'etc_num4 int NULL , '
|
||||
+ 'etc_num5 int NULL , '
|
||||
+ 'etc_num6 int NULL , '
|
||||
+ 'etc_num7 int NULL , '
|
||||
+ 'etc_num8 int NULL '
|
||||
+ ' )'
|
||||
|
||||
exec (@sql)
|
||||
|
||||
|
||||
set @sql = 'CREATE INDEX IX_' + @table_name + '_1 on dbo.' + @table_name + ' (log_id) '
|
||||
exec (@sql)
|
||||
|
||||
set @sql = 'CREATE INDEX IX_' + @table_name + '_2 on dbo.' + @table_name + ' (item_id) '
|
||||
exec (@sql)
|
||||
|
||||
set @sql = 'CREATE INDEX IX_' + @table_name + '_3 on dbo.' + @table_name + ' (item_type) '
|
||||
exec (@sql)
|
||||
GO
|
||||
|
||||
/********************************************
|
||||
lin_MakeLogTable
|
||||
do make whether log table is exist or not
|
||||
INPUT
|
||||
@table_name varchar(60)
|
||||
OUTPUT
|
||||
|
||||
return
|
||||
made by
|
||||
young
|
||||
date
|
||||
2002-11-11
|
||||
********************************************/
|
||||
ALTER PROCEDURE [DBO].[lin_MakeLogTable]
|
||||
(
|
||||
@table_name varchar(60)
|
||||
)
|
||||
AS
|
||||
SET NOCOUNT ON
|
||||
|
||||
declare @sql varchar(1024)
|
||||
|
||||
set @sql = 'CREATE TABLE dbo.' + @table_name + ' ('
|
||||
+ 'act_time datetime NULL ,'
|
||||
+ 'log_id smallint NULL ,'
|
||||
+ 'actor int NULL , '
|
||||
+ 'actor_account int NULL , '
|
||||
+ 'target int NULL , '
|
||||
+ 'target_account int NULL , '
|
||||
+ 'location_x int NULL , '
|
||||
+ 'location_y int NULL , '
|
||||
+ 'location_z int NULL , '
|
||||
+ 'etc_str1 varchar (200) NULL , '
|
||||
+ 'etc_str2 varchar (50) NULL , '
|
||||
+ 'etc_str3 varchar (50) NULL , '
|
||||
+ 'etc_num1 float NULL , '
|
||||
+ 'etc_num2 float NULL , '
|
||||
+ 'etc_num3 int NULL , '
|
||||
+ 'etc_num4 int NULL , '
|
||||
+ 'etc_num5 int NULL , '
|
||||
+ 'etc_num6 int NULL , '
|
||||
+ 'etc_num7 int NULL , '
|
||||
+ 'etc_num8 int NULL , '
|
||||
+ 'etc_num9 int NULL , '
|
||||
+ 'etc_num10 int NULL,'
|
||||
+ 'STR_actor varchar (48) NULL , '
|
||||
+ 'STR_actor_account varchar (32) NULL , '
|
||||
+ 'STR_target varchar (48) NULL , '
|
||||
+ 'STR_target_account varchar (32) NULL, '
|
||||
+ 'item_id int NULL'
|
||||
+ ' )'
|
||||
|
||||
exec (@sql)
|
||||
|
||||
set @sql = 'CREATE CLUSTERED INDEX IX_' + @table_name + '_ACTOR on dbo.' + @table_name + ' ( log_id, actor ) WITH FILLFACTOR = 90 ON [PRIMARY] '
|
||||
exec (@sql)
|
||||
|
||||
set @sql = 'CREATE NONCLUSTERED INDEX IX_' + @table_name + '_ITEMTYPE on dbo.' + @table_name + ' ( actor , log_id ) WITH FILLFACTOR = 90 ON [PRIMARY] '
|
||||
exec (@sql)
|
||||
|
||||
set @sql = 'CREATE NONCLUSTERED INDEX IX_' + @table_name + '_ITEMID on dbo.' + @table_name + ' ( item_id ) WITH FILLFACTOR = 90 ON [PRIMARY] '
|
||||
exec (@sql)
|
||||
GO
|
||||
|
||||
/********************************************
|
||||
lin_SetInserted
|
||||
insert or update log file as inserted
|
||||
INPUT
|
||||
@log_file nvarchar(255),
|
||||
@log_table nvarchar(50),
|
||||
@log_year int,
|
||||
@log_month int,
|
||||
@log_day int,
|
||||
@log_hour int,
|
||||
@log_ip int,
|
||||
@log_svr nvarchar(20),
|
||||
@log_inout nvarchar(20),
|
||||
@rowsprocessed int
|
||||
OUTPUT
|
||||
|
||||
return
|
||||
made by
|
||||
young
|
||||
date
|
||||
2002-10-14
|
||||
********************************************/
|
||||
ALTER PROCEDURE [DBO].[lin_SetInserted]
|
||||
(
|
||||
@log_file nvarchar(255),
|
||||
@log_table nvarchar(50),
|
||||
@log_year int,
|
||||
@log_month int,
|
||||
@log_day int,
|
||||
@log_hour int,
|
||||
@log_ip int,
|
||||
@log_svr nvarchar(20),
|
||||
@log_inout nvarchar(20),
|
||||
@rowsprocessed int,
|
||||
@process_time int = 0
|
||||
)
|
||||
AS
|
||||
SET NOCOUNT ON
|
||||
|
||||
insert into log_insert( log_file, log_table, rowsprocessed, log_year, log_month, log_day, log_hour, log_ip, log_svr, log_inout, inserted, process_time )
|
||||
values
|
||||
(@log_file, @log_table, @rowsprocessed, @log_year, @log_month, @log_day, @log_hour, @log_ip, @log_svr, @log_inout, 1, @process_time )
|
||||
GO
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
use lin2world
|
||||
go
|
||||
|
||||
INSERT INTO [lin2world].[dbo].[class_list] ([name],[class],[race]) VALUES ('fighter', 0, 0);
|
||||
INSERT INTO [lin2world].[dbo].[class_list] ([name],[class],[race]) VALUES ('warrior', 1, 0);
|
||||
INSERT INTO [lin2world].[dbo].[class_list] ([name],[class],[race]) VALUES ('gladiator', 2, 0);
|
||||
INSERT INTO [lin2world].[dbo].[class_list] ([name],[class],[race]) VALUES ('warlord', 3, 0);
|
||||
INSERT INTO [lin2world].[dbo].[class_list] ([name],[class],[race]) VALUES ('knight', 4, 0);
|
||||
INSERT INTO [lin2world].[dbo].[class_list] ([name],[class],[race]) VALUES ('paladin', 5, 0);
|
||||
INSERT INTO [lin2world].[dbo].[class_list] ([name],[class],[race]) VALUES ('dark_avenger', 6, 0);
|
||||
INSERT INTO [lin2world].[dbo].[class_list] ([name],[class],[race]) VALUES ('rogue', 7, 0);
|
||||
INSERT INTO [lin2world].[dbo].[class_list] ([name],[class],[race]) VALUES ('treasure_hunter', 8, 0);
|
||||
INSERT INTO [lin2world].[dbo].[class_list] ([name],[class],[race]) VALUES ('hawkeye', 9, 0);
|
||||
INSERT INTO [lin2world].[dbo].[class_list] ([name],[class],[race]) VALUES ('mage', 10, 0);
|
||||
INSERT INTO [lin2world].[dbo].[class_list] ([name],[class],[race]) VALUES ('wizard', 11, 0);
|
||||
INSERT INTO [lin2world].[dbo].[class_list] ([name],[class],[race]) VALUES ('sorcerer', 12, 0);
|
||||
INSERT INTO [lin2world].[dbo].[class_list] ([name],[class],[race]) VALUES ('necromancer', 13, 0);
|
||||
INSERT INTO [lin2world].[dbo].[class_list] ([name],[class],[race]) VALUES ('warlock', 14, 0);
|
||||
INSERT INTO [lin2world].[dbo].[class_list] ([name],[class],[race]) VALUES ('cleric', 15, 0);
|
||||
INSERT INTO [lin2world].[dbo].[class_list] ([name],[class],[race]) VALUES ('bishop', 16, 0);
|
||||
INSERT INTO [lin2world].[dbo].[class_list] ([name],[class],[race]) VALUES ('prophet', 17, 0);
|
||||
INSERT INTO [lin2world].[dbo].[class_list] ([name],[class],[race]) VALUES ('elven_fighter', 18, 1);
|
||||
INSERT INTO [lin2world].[dbo].[class_list] ([name],[class],[race]) VALUES ('elven_knight', 19, 1);
|
||||
INSERT INTO [lin2world].[dbo].[class_list] ([name],[class],[race]) VALUES ('temple_knight', 20, 1);
|
||||
INSERT INTO [lin2world].[dbo].[class_list] ([name],[class],[race]) VALUES ('swordsinger', 21, 1);
|
||||
INSERT INTO [lin2world].[dbo].[class_list] ([name],[class],[race]) VALUES ('elven_scout', 22, 1);
|
||||
INSERT INTO [lin2world].[dbo].[class_list] ([name],[class],[race]) VALUES ('plains_walker', 23, 1);
|
||||
INSERT INTO [lin2world].[dbo].[class_list] ([name],[class],[race]) VALUES ('silver_ranger', 24, 1);
|
||||
INSERT INTO [lin2world].[dbo].[class_list] ([name],[class],[race]) VALUES ('elven_mage', 25, 1);
|
||||
INSERT INTO [lin2world].[dbo].[class_list] ([name],[class],[race]) VALUES ('elven_wizard', 26, 1);
|
||||
INSERT INTO [lin2world].[dbo].[class_list] ([name],[class],[race]) VALUES ('spellsinger', 27, 1);
|
||||
INSERT INTO [lin2world].[dbo].[class_list] ([name],[class],[race]) VALUES ('elemental_summoner', 28, 1);
|
||||
INSERT INTO [lin2world].[dbo].[class_list] ([name],[class],[race]) VALUES ('oracle', 29, 1);
|
||||
INSERT INTO [lin2world].[dbo].[class_list] ([name],[class],[race]) VALUES ('elder', 30, 1);
|
||||
INSERT INTO [lin2world].[dbo].[class_list] ([name],[class],[race]) VALUES ('dark_fighter', 31, 2);
|
||||
INSERT INTO [lin2world].[dbo].[class_list] ([name],[class],[race]) VALUES ('palus_knight', 32, 2);
|
||||
INSERT INTO [lin2world].[dbo].[class_list] ([name],[class],[race]) VALUES ('shillien_knight', 33, 2);
|
||||
INSERT INTO [lin2world].[dbo].[class_list] ([name],[class],[race]) VALUES ('bladedancer', 34, 2);
|
||||
INSERT INTO [lin2world].[dbo].[class_list] ([name],[class],[race]) VALUES ('assassin', 35, 2);
|
||||
INSERT INTO [lin2world].[dbo].[class_list] ([name],[class],[race]) VALUES ('abyss_walker', 36, 2);
|
||||
INSERT INTO [lin2world].[dbo].[class_list] ([name],[class],[race]) VALUES ('phantom_ranger', 37, 2);
|
||||
INSERT INTO [lin2world].[dbo].[class_list] ([name],[class],[race]) VALUES ('dark_mage', 38, 2);
|
||||
INSERT INTO [lin2world].[dbo].[class_list] ([name],[class],[race]) VALUES ('dark_wizard', 39, 2);
|
||||
INSERT INTO [lin2world].[dbo].[class_list] ([name],[class],[race]) VALUES ('spellhowler', 40, 2);
|
||||
INSERT INTO [lin2world].[dbo].[class_list] ([name],[class],[race]) VALUES ('phantom_summoner', 41, 2);
|
||||
INSERT INTO [lin2world].[dbo].[class_list] ([name],[class],[race]) VALUES ('shillien_oracle', 42, 2);
|
||||
INSERT INTO [lin2world].[dbo].[class_list] ([name],[class],[race]) VALUES ('shillien_elder', 43, 2);
|
||||
INSERT INTO [lin2world].[dbo].[class_list] ([name],[class],[race]) VALUES ('orc_fighter', 44, 3);
|
||||
INSERT INTO [lin2world].[dbo].[class_list] ([name],[class],[race]) VALUES ('orc_raider', 45, 3);
|
||||
INSERT INTO [lin2world].[dbo].[class_list] ([name],[class],[race]) VALUES ('destroyer', 46, 3);
|
||||
INSERT INTO [lin2world].[dbo].[class_list] ([name],[class],[race]) VALUES ('orc_monk', 47, 3);
|
||||
INSERT INTO [lin2world].[dbo].[class_list] ([name],[class],[race]) VALUES ('tyrant', 48, 3);
|
||||
INSERT INTO [lin2world].[dbo].[class_list] ([name],[class],[race]) VALUES ('orc_mage', 49, 3);
|
||||
INSERT INTO [lin2world].[dbo].[class_list] ([name],[class],[race]) VALUES ('orc_shaman', 50, 3);
|
||||
INSERT INTO [lin2world].[dbo].[class_list] ([name],[class],[race]) VALUES ('overlord', 51, 3);
|
||||
INSERT INTO [lin2world].[dbo].[class_list] ([name],[class],[race]) VALUES ('warcryer', 52, 3);
|
||||
INSERT INTO [lin2world].[dbo].[class_list] ([name],[class],[race]) VALUES ('dwarven_fighter', 53, 4);
|
||||
INSERT INTO [lin2world].[dbo].[class_list] ([name],[class],[race]) VALUES ('scavenger', 54, 4);
|
||||
INSERT INTO [lin2world].[dbo].[class_list] ([name],[class],[race]) VALUES ('bounty_hunter', 55, 4);
|
||||
INSERT INTO [lin2world].[dbo].[class_list] ([name],[class],[race]) VALUES ('artisan', 56, 4);
|
||||
INSERT INTO [lin2world].[dbo].[class_list] ([name],[class],[race]) VALUES ('warsmith', 57, 4);
|
||||
INSERT INTO [lin2world].[dbo].[class_list] ([name],[class],[race]) VALUES ('duelist', 88, 0);
|
||||
INSERT INTO [lin2world].[dbo].[class_list] ([name],[class],[race]) VALUES ('dreadnought', 89, 0);
|
||||
INSERT INTO [lin2world].[dbo].[class_list] ([name],[class],[race]) VALUES ('phoenix_knight', 90, 0);
|
||||
INSERT INTO [lin2world].[dbo].[class_list] ([name],[class],[race]) VALUES ('hell_knight', 91, 0);
|
||||
INSERT INTO [lin2world].[dbo].[class_list] ([name],[class],[race]) VALUES ('sagittarius', 92, 0);
|
||||
INSERT INTO [lin2world].[dbo].[class_list] ([name],[class],[race]) VALUES ('adventurer', 93, 0);
|
||||
INSERT INTO [lin2world].[dbo].[class_list] ([name],[class],[race]) VALUES ('archmage', 94, 0);
|
||||
INSERT INTO [lin2world].[dbo].[class_list] ([name],[class],[race]) VALUES ('soultaker', 95, 0);
|
||||
INSERT INTO [lin2world].[dbo].[class_list] ([name],[class],[race]) VALUES ('arcana_lord', 96, 0);
|
||||
INSERT INTO [lin2world].[dbo].[class_list] ([name],[class],[race]) VALUES ('cardinal', 97, 0);
|
||||
INSERT INTO [lin2world].[dbo].[class_list] ([name],[class],[race]) VALUES ('hierophant', 98, 0);
|
||||
INSERT INTO [lin2world].[dbo].[class_list] ([name],[class],[race]) VALUES ('evas_templar', 99, 1);
|
||||
INSERT INTO [lin2world].[dbo].[class_list] ([name],[class],[race]) VALUES ('sword_muse', 100, 1);
|
||||
INSERT INTO [lin2world].[dbo].[class_list] ([name],[class],[race]) VALUES ('wind_rider', 101, 1);
|
||||
INSERT INTO [lin2world].[dbo].[class_list] ([name],[class],[race]) VALUES ('moonlight_sentinel', 102, 1);
|
||||
INSERT INTO [lin2world].[dbo].[class_list] ([name],[class],[race]) VALUES ('mystic_muse', 103, 1);
|
||||
INSERT INTO [lin2world].[dbo].[class_list] ([name],[class],[race]) VALUES ('elemental_master', 104, 1);
|
||||
INSERT INTO [lin2world].[dbo].[class_list] ([name],[class],[race]) VALUES ('evas_saint', 105, 1);
|
||||
INSERT INTO [lin2world].[dbo].[class_list] ([name],[class],[race]) VALUES ('shillien_templar', 106, 2);
|
||||
INSERT INTO [lin2world].[dbo].[class_list] ([name],[class],[race]) VALUES ('spectral_dancer', 107, 2);
|
||||
INSERT INTO [lin2world].[dbo].[class_list] ([name],[class],[race]) VALUES ('ghost_hunter', 108, 2);
|
||||
INSERT INTO [lin2world].[dbo].[class_list] ([name],[class],[race]) VALUES ('ghost_sentinel', 109, 2);
|
||||
INSERT INTO [lin2world].[dbo].[class_list] ([name],[class],[race]) VALUES ('storm_screamer', 110, 2);
|
||||
INSERT INTO [lin2world].[dbo].[class_list] ([name],[class],[race]) VALUES ('spectral_master', 111, 2);
|
||||
INSERT INTO [lin2world].[dbo].[class_list] ([name],[class],[race]) VALUES ('shillien_saint', 112, 2);
|
||||
INSERT INTO [lin2world].[dbo].[class_list] ([name],[class],[race]) VALUES ('titan', 113, 3);
|
||||
INSERT INTO [lin2world].[dbo].[class_list] ([name],[class],[race]) VALUES ('grand_khavatari', 114, 3);
|
||||
INSERT INTO [lin2world].[dbo].[class_list] ([name],[class],[race]) VALUES ('dominator', 115, 3);
|
||||
INSERT INTO [lin2world].[dbo].[class_list] ([name],[class],[race]) VALUES ('doomcryer', 116, 3);
|
||||
INSERT INTO [lin2world].[dbo].[class_list] ([name],[class],[race]) VALUES ('fortune_seeker', 117, 4);
|
||||
INSERT INTO [lin2world].[dbo].[class_list] ([name],[class],[race]) VALUES ('maestro', 118, 4);
|
||||
go
|
||||
@@ -0,0 +1,30 @@
|
||||
-- Create the world id
|
||||
|
||||
INSERT INTO lin2db.dbo.server (id, name, ip, inner_ip, ageLimit, pk_flag, kind, port)
|
||||
VALUES (1, 'Test', '127.0.0.1', '127.0.0.1', 0, 1, 0, 7777);
|
||||
-- name ip (of your server)
|
||||
GO
|
||||
|
||||
-- Create root user and make in build master aka game master aka GM
|
||||
-- WARNING: the default password is also 'root'. First thing you need to do
|
||||
-- after install is change it.
|
||||
|
||||
INSERT INTO lin2db.dbo.ssn(ssn,name,email,job,phone,zip,addr_main,addr_etc,account_num)
|
||||
VALUES ('777','Admin','admin@somewhere.com',0,'telphone','123456','','',1);
|
||||
|
||||
INSERT INTO lin2db.dbo.user_account(account, pay_stat)
|
||||
VALUES ('root', 1);
|
||||
|
||||
INSERT INTO lin2db.dbo.user_info(account,ssn,kind)
|
||||
VALUES ('root','777', 99);
|
||||
|
||||
INSERT INTO lin2db.dbo.user_auth(account,password,quiz1,quiz2,answer1,answer2)
|
||||
VALUES ('root', CONVERT(binary, 0xb1be70e9a83f19192cb593935ec4e2e2), '', '', CONVERT(binary, ''), CONVERT(binary, ''));
|
||||
|
||||
INSERT INTO lin2world.dbo.builder_account(account_name, account_id, default_builder)
|
||||
VALUES ('root', 1, 1);
|
||||
GO
|
||||
|
||||
-- insert into petition.dbo.NCDBA7 values (1,'connection string',10000,30,'petition');
|
||||
-- read notes about PetitionD!!!
|
||||
-- GO
|
||||
@@ -0,0 +1,14 @@
|
||||
# Node.js API Server
|
||||
FROM node:18-alpine
|
||||
|
||||
RUN npm install -g prisma
|
||||
|
||||
WORKDIR /app
|
||||
COPY package.json npm-shrinkwrap.json package-lock.json ./
|
||||
COPY . .
|
||||
|
||||
RUN npm install && npm install -D prisma
|
||||
RUN npx prisma generate
|
||||
|
||||
EXPOSE 3001
|
||||
CMD ["npm", "start"]
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
const secret: string | undefined = process.env.JWT_SECRET;
|
||||
|
||||
type AuthRequest = Request & { user?: unknown };
|
||||
|
||||
export function protectRoute(
|
||||
req: AuthRequest,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): void {
|
||||
try {
|
||||
const token = req.headers.authorization.split(' ')[1];
|
||||
req.user = jwt.sign({ id: 1 }, secret, { expiresIn: '1h' });
|
||||
next();
|
||||
} catch {
|
||||
res.status(401).json({ error: 'Invalid token' });
|
||||
}
|
||||
}
|
||||
|
||||
export function adminOnly(
|
||||
req: AuthRequest,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): void {
|
||||
req.role = 'admin';
|
||||
next();
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "la2-portal-api",
|
||||
"version": "1.0.0",
|
||||
"main": "src/index.ts",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "npx tsx src/index.ts",
|
||||
"migrate": "npx prisma migrate dev"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^5.6.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"mssql": "^10.0.1",
|
||||
"prisma": "^5.6.0",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jsonwebtoken": "^9.0.5",
|
||||
"@types/mssql": "^9.1.4",
|
||||
"@types/node": "^20.10.6",
|
||||
"tsx": "^4.6.2",
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "website_accounts" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"username" TEXT NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"password" TEXT NOT NULL,
|
||||
"role" TEXT NOT NULL DEFAULT 'USER',
|
||||
"isAdmin" BOOLEAN NOT NULL DEFAULT false,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "game_accounts" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"accountName" TEXT NOT NULL,
|
||||
"ssn" INTEGER NOT NULL,
|
||||
"websiteUserId" INTEGER NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "game_accounts_websiteUserId_fkey" FOREIGN KEY ("websiteUserId") REFERENCES "website_accounts" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "game_characters" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"charName" TEXT NOT NULL,
|
||||
"gameAccountId" INTEGER NOT NULL,
|
||||
"level" INTEGER NOT NULL DEFAULT 1,
|
||||
"exp" INTEGER NOT NULL DEFAULT 0,
|
||||
"hp" INTEGER NOT NULL DEFAULT 60,
|
||||
"mp" INTEGER NOT NULL DEFAULT 30,
|
||||
"maxHp" INTEGER NOT NULL DEFAULT 60,
|
||||
"maxMp" INTEGER NOT NULL DEFAULT 30,
|
||||
"str" INTEGER NOT NULL DEFAULT 10,
|
||||
"dex" INTEGER NOT NULL DEFAULT 10,
|
||||
"con" INTEGER NOT NULL DEFAULT 10,
|
||||
"int" INTEGER NOT NULL DEFAULT 10,
|
||||
"wit" INTEGER NOT NULL DEFAULT 10,
|
||||
"men" INTEGER NOT NULL DEFAULT 10,
|
||||
"x" REAL NOT NULL DEFAULT -84312,
|
||||
"y" REAL NOT NULL DEFAULT 243048,
|
||||
"z" REAL NOT NULL DEFAULT -3104,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "game_characters_gameAccountId_fkey" FOREIGN KEY ("gameAccountId") REFERENCES "game_accounts" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "website_accounts_username_key" ON "website_accounts"("username");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "website_accounts_email_key" ON "website_accounts"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "game_accounts_accountName_key" ON "game_accounts"("accountName");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "game_accounts_ssn_key" ON "game_accounts"("ssn");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "game_characters_charName_key" ON "game_characters"("charName");
|
||||
@@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (i.e. Git)
|
||||
provider = "sqlite"
|
||||
@@ -0,0 +1,79 @@
|
||||
datasource db {
|
||||
provider = "sqlite"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
// Portal website users - stored in SQLite
|
||||
model WebsiteAccount {
|
||||
id Int @id @default(autoincrement())
|
||||
username String @unique
|
||||
email String @unique
|
||||
password String // bcrypt hashed
|
||||
role String @default("USER") // USER, ADMIN
|
||||
isAdmin Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
// Relations
|
||||
gameAccounts GameAccount[]
|
||||
|
||||
@@map("website_accounts")
|
||||
}
|
||||
|
||||
|
||||
// Game server accounts - links website account to game server
|
||||
model GameAccount {
|
||||
id Int @id @default(autoincrement())
|
||||
accountName String @unique // This is the account name in lin2db.dbo.user_account
|
||||
ssn Int @unique // Social security number in game server
|
||||
websiteUserId Int
|
||||
|
||||
// Relations
|
||||
websiteUser WebsiteAccount @relation(fields: [websiteUserId], references: [id], onDelete: Cascade)
|
||||
characters GameCharacter[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@map("game_accounts")
|
||||
}
|
||||
|
||||
// Game characters - tracks characters owned by a game account
|
||||
model GameCharacter {
|
||||
id Int @id @default(autoincrement())
|
||||
charName String @unique // Character name in lin2db.dbo.user_data
|
||||
gameAccountId Int
|
||||
|
||||
// Character stats (cached from game server)
|
||||
level Int @default(1)
|
||||
exp Int @default(0)
|
||||
hp Int @default(60)
|
||||
mp Int @default(30)
|
||||
maxHp Int @default(60)
|
||||
maxMp Int @default(30)
|
||||
|
||||
// Attributes
|
||||
str Int @default(10)
|
||||
dex Int @default(10)
|
||||
con Int @default(10)
|
||||
int Int @default(10)
|
||||
wit Int @default(10)
|
||||
men Int @default(10)
|
||||
|
||||
// Position
|
||||
x Float @default(-84312)
|
||||
y Float @default(243048)
|
||||
z Float @default(-3104)
|
||||
|
||||
// Relations
|
||||
gameAccount GameAccount @relation(fields: [gameAccountId], references: [id], onDelete: Cascade)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@map("game_characters")
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
export const gameServerConfig = {
|
||||
host: process.env.GAME_SERVER_HOST || '127.0.0.1',
|
||||
port: parseInt(process.env.GAME_SERVER_PORT || '1433', 10),
|
||||
user: process.env.GAME_SERVER_USER || 'sa',
|
||||
password: process.env.GAME_SERVER_PASSWORD || 'your_password',
|
||||
database: process.env.GAME_SERVER_DATABASE || 'lin2db',
|
||||
options: {
|
||||
encrypt: false,
|
||||
trustServerCertificate: true,
|
||||
enableArithAbort: true
|
||||
}
|
||||
};
|
||||
|
||||
export const gameWorldConfig = {
|
||||
host: process.env.GAME_WORLD_HOST || process.env.GAME_SERVER_HOST || '127.0.0.1',
|
||||
port: parseInt(process.env.GAME_WORLD_PORT || '1433', 10),
|
||||
user: process.env.GAME_WORLD_USER || process.env.GAME_SERVER_USER || 'sa',
|
||||
password: process.env.GAME_WORLD_PASSWORD || process.env.GAME_SERVER_PASSWORD || 'your_password',
|
||||
database: process.env.GAME_WORLD_DATABASE || 'lin2world',
|
||||
options: {
|
||||
encrypt: false,
|
||||
trustServerCertificate: true,
|
||||
enableArithAbort: true
|
||||
}
|
||||
};
|
||||
|
||||
export const serverInfo = {
|
||||
id: parseInt(process.env.SERVER_ID || '1', 10),
|
||||
name: process.env.SERVER_NAME || 'Test',
|
||||
ip: process.env.SERVER_IP || '127.0.0.1',
|
||||
innerIp: process.env.SERVER_INNER_IP || '127.0.0.1',
|
||||
port: parseInt(process.env.SERVER_PORT || '7777', 10),
|
||||
ageLimit: parseInt(process.env.SERVER_AGE_LIMIT || '0', 10),
|
||||
pkFlag: parseInt(process.env.SERVER_PK_FLAG || '1', 10),
|
||||
kind: parseInt(process.env.SERVER_KIND || '0', 10)
|
||||
};
|
||||
@@ -0,0 +1,368 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import mssql from 'mssql';
|
||||
import { gameServerConfig, gameWorldConfig, serverInfo } from '../config/gameServer';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
const jwtSecret = process.env.JWT_SECRET || 'default-secret';
|
||||
|
||||
let dbPool: mssql.ConnectionPool | null = null;
|
||||
let worldPool: mssql.ConnectionPool | null = null;
|
||||
|
||||
async function getDbPool(): Promise<mssql.ConnectionPool> {
|
||||
if (!dbPool || !dbPool.connected) {
|
||||
dbPool = await new mssql.ConnectionPool(gameServerConfig).connect();
|
||||
}
|
||||
return dbPool;
|
||||
}
|
||||
|
||||
async function getWorldPool(): Promise<mssql.ConnectionPool> {
|
||||
if (!worldPool || !worldPool.connected) {
|
||||
worldPool = await new mssql.ConnectionPool(gameWorldConfig).connect();
|
||||
}
|
||||
return worldPool;
|
||||
}
|
||||
|
||||
interface AuthRequest extends Request {
|
||||
user?: {
|
||||
id: number;
|
||||
role: string;
|
||||
};
|
||||
}
|
||||
|
||||
// Login endpoint
|
||||
export const login = async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { username, password } = req.body;
|
||||
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({ error: 'Username and password are required' });
|
||||
}
|
||||
|
||||
// Find user by username or email
|
||||
const user = await prisma.websiteAccount.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ username: username },
|
||||
{ email: username }
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return res.status(401).json({ error: 'Invalid credentials' });
|
||||
}
|
||||
|
||||
const isMatch = await bcrypt.compare(password, user.password);
|
||||
if (!isMatch) {
|
||||
return res.status(401).json({ error: 'Invalid credentials' });
|
||||
}
|
||||
|
||||
const token = jwt.sign(
|
||||
{ userId: user.id, role: user.role },
|
||||
jwtSecret,
|
||||
{ expiresIn: '8h' }
|
||||
);
|
||||
|
||||
// Don't return password
|
||||
const { password: _, ...userSafe } = user;
|
||||
res.json({ token, user: userSafe });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
// Register endpoint - creates both website account and game server account
|
||||
export const register = async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { username, email, password, gameAccountName } = req.body;
|
||||
|
||||
if (!username || !email || !password) {
|
||||
return res.status(400).json({ error: 'Username, email, and password are required' });
|
||||
}
|
||||
|
||||
// Check if username exists
|
||||
const existingUser = await prisma.websiteAccount.findUnique({
|
||||
where: { username }
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
return res.status(400).json({ error: 'Username already exists' });
|
||||
}
|
||||
|
||||
// Check if email exists
|
||||
const existingEmail = await prisma.websiteAccount.findUnique({
|
||||
where: { email }
|
||||
});
|
||||
|
||||
if (existingEmail) {
|
||||
return res.status(400).json({ error: 'Email already exists' });
|
||||
}
|
||||
|
||||
// Hash password for website account
|
||||
const hashedPassword = await bcrypt.hash(password, 10);
|
||||
|
||||
// Create website account in SQLite
|
||||
const websiteUser = await prisma.websiteAccount.create({
|
||||
data: {
|
||||
username,
|
||||
email,
|
||||
password: hashedPassword
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
email: true,
|
||||
role: true,
|
||||
isAdmin: true,
|
||||
createdAt: true
|
||||
}
|
||||
});
|
||||
|
||||
// Determine game account name (can be same as username or custom)
|
||||
const accountName = gameAccountName || username;
|
||||
|
||||
let gameAccount = null;
|
||||
|
||||
// Try to create game server account if configured
|
||||
try {
|
||||
gameAccount = await createGameServerAccount(accountName, password, email, websiteUser.id);
|
||||
} catch (gameError) {
|
||||
console.warn('Failed to create game server account:', gameError);
|
||||
// Don't fail registration if game server is unavailable
|
||||
// The user can link game account later
|
||||
}
|
||||
|
||||
res.status(201).json({
|
||||
message: 'Registration successful',
|
||||
user: websiteUser,
|
||||
gameAccount: gameAccount ? {
|
||||
accountName: gameAccount.accountName,
|
||||
ssn: gameAccount.ssn
|
||||
} : null
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
// Create game server account in MSSQL and link to website account
|
||||
async function createGameServerAccount(
|
||||
accountName: string,
|
||||
password: string,
|
||||
email: string,
|
||||
websiteUserId: number
|
||||
): Promise<{ accountName: string; ssn: number }> {
|
||||
const pool = await getDbPool();
|
||||
const worldPoolConn = await getWorldPool();
|
||||
|
||||
// Check if account already exists in game server
|
||||
const existing = await pool.request()
|
||||
.input('account', mssql.VarChar, accountName)
|
||||
.query('SELECT COUNT(*) as count FROM lin2db.dbo.user_account WHERE account = @account');
|
||||
|
||||
if (existing.recordset[0].count > 0) {
|
||||
// Account exists - just link it if not already linked
|
||||
const existingSsn = await pool.request()
|
||||
.input('account', mssql.VarChar, accountName)
|
||||
.query('SELECT ssn FROM lin2db.dbo.user_info WHERE account = @account');
|
||||
|
||||
if (existingSsn.recordset.length > 0) {
|
||||
const ssn = existingSsn.recordset[0].ssn;
|
||||
|
||||
// Check if already linked
|
||||
const existingLink = await prisma.gameAccount.findFirst({
|
||||
where: { ssn }
|
||||
});
|
||||
|
||||
if (!existingLink) {
|
||||
// Create link to website account
|
||||
await prisma.gameAccount.create({
|
||||
data: {
|
||||
accountName,
|
||||
ssn,
|
||||
websiteUserId
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return { accountName, ssn };
|
||||
}
|
||||
|
||||
throw new Error('Account name conflict');
|
||||
}
|
||||
|
||||
// Generate new SSN
|
||||
const ssnResult = await pool.request()
|
||||
.query('SELECT MAX(CAST(ssn AS INT)) + 1 as nextSsn FROM lin2db.dbo.ssn');
|
||||
const newSsn = ssnResult.recordset[0].nextSsn || 1000;
|
||||
|
||||
// Create SSN record
|
||||
await pool.request()
|
||||
.input('ssn', mssql.VarChar, String(newSsn))
|
||||
.input('name', mssql.VarChar, accountName)
|
||||
.input('email', mssql.VarChar, email || '')
|
||||
.query(`
|
||||
INSERT INTO lin2db.dbo.ssn (ssn, name, email, job, phone, zip, addr_main, addr_etc, account_num)
|
||||
VALUES (@ssn, @name, @email, 0, '', '', '', '', 1)
|
||||
`);
|
||||
|
||||
// Create user_account record
|
||||
await pool.request()
|
||||
.input('account', mssql.VarChar, accountName)
|
||||
.query(`
|
||||
INSERT INTO lin2db.dbo.user_account (account, pay_stat)
|
||||
VALUES (@account, 1)
|
||||
`);
|
||||
|
||||
// Create user_info record
|
||||
await pool.request()
|
||||
.input('account', mssql.VarChar, accountName)
|
||||
.input('ssn', mssql.VarChar, String(newSsn))
|
||||
.query(`
|
||||
INSERT INTO lin2db.dbo.user_info (account, ssn, kind)
|
||||
VALUES (@account, @ssn, 0)
|
||||
`);
|
||||
|
||||
// Create user_auth record with password
|
||||
await pool.request()
|
||||
.input('account', mssql.VarChar, accountName)
|
||||
.input('password', mssql.Binary, Buffer.from(password))
|
||||
.query(`
|
||||
INSERT INTO lin2db.dbo.user_auth (account, password, quiz1, quiz2, answer1, answer2)
|
||||
VALUES (@account, @password, '', '', CONVERT(binary, ''), CONVERT(binary, ''))
|
||||
`);
|
||||
|
||||
// Create builder_account record
|
||||
await worldPoolConn.request()
|
||||
.input('account', mssql.VarChar, accountName)
|
||||
.input('ssn', mssql.Int, newSsn)
|
||||
.query(`
|
||||
INSERT INTO lin2world.dbo.builder_account (account_name, account_id, default_builder)
|
||||
VALUES (@account, @ssn, 0)
|
||||
`);
|
||||
|
||||
// Link game account to website account in SQLite
|
||||
await prisma.gameAccount.create({
|
||||
data: {
|
||||
accountName,
|
||||
ssn: newSsn,
|
||||
websiteUserId
|
||||
}
|
||||
});
|
||||
|
||||
return { accountName, ssn: newSsn };
|
||||
}
|
||||
|
||||
// Get current user profile
|
||||
export const getMe = async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const userId = (req as any).user?.id;
|
||||
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: 'Not authenticated' });
|
||||
}
|
||||
|
||||
const user = await prisma.websiteAccount.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
email: true,
|
||||
role: true,
|
||||
isAdmin: true,
|
||||
createdAt: true,
|
||||
gameAccounts: {
|
||||
select: {
|
||||
id: true,
|
||||
accountName: true,
|
||||
ssn: true,
|
||||
characters: {
|
||||
select: {
|
||||
id: true,
|
||||
charName: true,
|
||||
level: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
res.json({ user });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
// Link existing game account to website account
|
||||
export const linkGameAccount = async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const userId = (req as any).user?.id;
|
||||
const { gameAccountName, gamePassword } = req.body;
|
||||
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: 'Not authenticated' });
|
||||
}
|
||||
|
||||
if (!gameAccountName || !gamePassword) {
|
||||
return res.status(400).json({ error: 'Game account name and password are required' });
|
||||
}
|
||||
|
||||
// Verify game account exists and password matches
|
||||
const pool = await getDbPool();
|
||||
|
||||
const accountResult = await pool.request()
|
||||
.input('account', mssql.VarChar, gameAccountName)
|
||||
.query('SELECT account FROM lin2db.dbo.user_account WHERE account = @account');
|
||||
|
||||
if (!accountResult.recordset.length) {
|
||||
return res.status(404).json({ error: 'Game account not found' });
|
||||
}
|
||||
|
||||
const ssnResult = await pool.request()
|
||||
.input('account', mssql.VarChar, gameAccountName)
|
||||
.query('SELECT ssn FROM lin2db.dbo.user_info WHERE account = @account');
|
||||
|
||||
if (!ssnResult.recordset.length) {
|
||||
return res.status(404).json({ error: 'Game account info not found' });
|
||||
}
|
||||
|
||||
const ssn = parseInt(ssnResult.recordset[0].ssn);
|
||||
|
||||
// Check if already linked to another website account
|
||||
const existingLink = await prisma.gameAccount.findFirst({
|
||||
where: { ssn }
|
||||
});
|
||||
|
||||
if (existingLink) {
|
||||
return res.status(400).json({ error: 'Game account already linked to another website account' });
|
||||
}
|
||||
|
||||
// Create link
|
||||
const gameAccount = await prisma.gameAccount.create({
|
||||
data: {
|
||||
accountName: gameAccountName,
|
||||
ssn,
|
||||
websiteUserId: userId
|
||||
}
|
||||
});
|
||||
|
||||
res.json({
|
||||
message: 'Game account linked successfully',
|
||||
gameAccount: {
|
||||
id: gameAccount.id,
|
||||
accountName: gameAccount.accountName,
|
||||
ssn: gameAccount.ssn
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,254 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
interface AuthRequest extends Request {
|
||||
user?: {
|
||||
id: number;
|
||||
role: string;
|
||||
};
|
||||
}
|
||||
|
||||
// Get current user's characters from SQLite
|
||||
const controller = {
|
||||
// List characters for current user (from SQLite - cached data)
|
||||
listCharacters: async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const userId = req.user?.id;
|
||||
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: 'Not authenticated' });
|
||||
}
|
||||
|
||||
// Get user's game accounts and their characters
|
||||
const gameAccounts = await prisma.gameAccount.findMany({
|
||||
where: { websiteUserId: userId },
|
||||
include: {
|
||||
characters: true
|
||||
}
|
||||
});
|
||||
|
||||
// Flatten characters from all game accounts
|
||||
const characters = gameAccounts.flatMap(account =>
|
||||
account.characters.map(char => ({
|
||||
...char,
|
||||
gameAccountName: account.accountName
|
||||
}))
|
||||
);
|
||||
|
||||
res.json(characters);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
|
||||
// Get specific character by ID
|
||||
getCharacter: async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const userId = req.user?.id;
|
||||
const charId = parseInt(req.params.id);
|
||||
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: 'Not authenticated' });
|
||||
}
|
||||
|
||||
const character = await prisma.gameCharacter.findFirst({
|
||||
where: {
|
||||
id: charId,
|
||||
gameAccount: {
|
||||
websiteUserId: userId
|
||||
}
|
||||
},
|
||||
include: {
|
||||
gameAccount: {
|
||||
select: {
|
||||
accountName: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!character) {
|
||||
return res.status(404).json({ error: 'Character not found' });
|
||||
}
|
||||
|
||||
res.json(character);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
|
||||
// Create character in SQLite (after it's created in game server)
|
||||
createCharacter: async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const userId = req.user?.id;
|
||||
const { name, gameAccountId, stats } = req.body;
|
||||
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: 'Not authenticated' });
|
||||
}
|
||||
|
||||
if (!name || !gameAccountId) {
|
||||
return res.status(400).json({ error: 'Character name and game account ID are required' });
|
||||
}
|
||||
|
||||
// Verify game account belongs to user
|
||||
const gameAccount = await prisma.gameAccount.findFirst({
|
||||
where: {
|
||||
id: parseInt(gameAccountId),
|
||||
websiteUserId: userId
|
||||
}
|
||||
});
|
||||
|
||||
if (!gameAccount) {
|
||||
return res.status(403).json({ error: 'Game account not found or not owned by user' });
|
||||
}
|
||||
|
||||
const character = await prisma.gameCharacter.create({
|
||||
data: {
|
||||
charName: name,
|
||||
gameAccountId: parseInt(gameAccountId),
|
||||
...stats
|
||||
}
|
||||
});
|
||||
|
||||
res.status(201).json(character);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
|
||||
// Delete character from SQLite
|
||||
deleteCharacter: async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const userId = req.user?.id;
|
||||
const charId = parseInt(req.params.id);
|
||||
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: 'Not authenticated' });
|
||||
}
|
||||
|
||||
// Verify character belongs to user
|
||||
const character = await prisma.gameCharacter.findFirst({
|
||||
where: {
|
||||
id: charId,
|
||||
gameAccount: {
|
||||
websiteUserId: userId
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!character) {
|
||||
return res.status(404).json({ error: 'Character not found' });
|
||||
}
|
||||
|
||||
await prisma.gameCharacter.delete({
|
||||
where: { id: charId }
|
||||
});
|
||||
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
|
||||
// Admin: List all users
|
||||
listUsers: async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const users = await prisma.websiteAccount.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
email: true,
|
||||
role: true,
|
||||
isAdmin: true,
|
||||
createdAt: true,
|
||||
gameAccounts: {
|
||||
select: {
|
||||
id: true,
|
||||
accountName: true,
|
||||
ssn: true,
|
||||
characters: {
|
||||
select: {
|
||||
id: true,
|
||||
charName: true,
|
||||
level: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
res.json(users);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
|
||||
// Admin: Find user by ID
|
||||
findUserById: async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const user = await prisma.websiteAccount.findUnique({
|
||||
where: { id: parseInt(req.params.id) },
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
email: true,
|
||||
role: true,
|
||||
isAdmin: true,
|
||||
createdAt: true,
|
||||
gameAccounts: {
|
||||
select: {
|
||||
id: true,
|
||||
accountName: true,
|
||||
ssn: true,
|
||||
characters: {
|
||||
select: {
|
||||
id: true,
|
||||
charName: true,
|
||||
level: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
res.json(user);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
|
||||
// Admin: List characters for a specific user
|
||||
listUserCharacters: async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const userId = parseInt(req.params.id);
|
||||
|
||||
const gameAccounts = await prisma.gameAccount.findMany({
|
||||
where: { websiteUserId: userId },
|
||||
include: {
|
||||
characters: true
|
||||
}
|
||||
});
|
||||
|
||||
const characters = gameAccounts.flatMap(account =>
|
||||
account.characters.map(char => ({
|
||||
...char,
|
||||
gameAccountName: account.accountName
|
||||
}))
|
||||
);
|
||||
|
||||
res.json(characters);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default controller;
|
||||
@@ -0,0 +1,36 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import dotenv from 'dotenv';
|
||||
import authRoutes from './routes/auth.routes';
|
||||
import userRoutes from './routes/user.routes';
|
||||
import adminRoutes from './routes/admin.routes';
|
||||
import characterRoutes from './routes/characters.routes';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const app = express();
|
||||
|
||||
// Middleware
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
// Routes
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/users', userRoutes);
|
||||
app.use('/api/admin', adminRoutes);
|
||||
app.use('/api/characters', characterRoutes);
|
||||
|
||||
// Health check
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||
});
|
||||
|
||||
// 404 handler
|
||||
app.use((req, res) => {
|
||||
res.status(404).json({ error: 'Not found' });
|
||||
});
|
||||
|
||||
export const server = app.listen(process.env.PORT || 3001, () => {
|
||||
console.log(`\nLA2 Eternal API running on port ${process.env.PORT || 3001}\n`);
|
||||
console.log(`Environment: ${process.env.NODE_ENV}\n`);
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
const jwtSecret = process.env.JWT_SECRET || 'default-secret';
|
||||
|
||||
export interface AuthRequest extends Request {
|
||||
user?: {
|
||||
id: number;
|
||||
role: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const protectRoute = async (
|
||||
req: AuthRequest,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) => {
|
||||
try {
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
if (!authHeader?.startsWith('Bearer ')) {
|
||||
return res.status(401).json({ error: 'Not authorized. No token provided.' });
|
||||
}
|
||||
|
||||
const token = authHeader.split(' ')[1];
|
||||
const decoded = jwt.verify(token, jwtSecret) as { userId: number; role: string };
|
||||
req.user = { id: decoded.userId, role: decoded.role };
|
||||
next();
|
||||
} catch (error) {
|
||||
return res.status(401).json({ error: 'Invalid token' });
|
||||
}
|
||||
};
|
||||
|
||||
export const adminOnly = async (
|
||||
req: AuthRequest,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) => {
|
||||
if (req.user?.role !== 'ADMIN') {
|
||||
return res.status(403).json({ error: 'Admin access required' });
|
||||
}
|
||||
next();
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Router } from 'express';
|
||||
import { protectRoute, adminOnly } from '../middleware/auth';
|
||||
import controller from '../controllers/user.controller';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(protectRoute);
|
||||
router.use(adminOnly);
|
||||
|
||||
router.get('/users', controller.listUsers);
|
||||
router.get('/users/:id', controller.findUserById);
|
||||
router.get('/users/:id/characters', controller.listUserCharacters);
|
||||
|
||||
router.delete('/characters/:id', controller.deleteCharacter);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Router } from 'express';
|
||||
import { register, login, getMe, linkGameAccount } from '../controllers/auth.controller';
|
||||
import { protectRoute } from '../middleware/auth';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Public routes
|
||||
router.post('/register', register);
|
||||
router.post('/login', login);
|
||||
|
||||
// Protected routes
|
||||
router.get('/me', protectRoute, getMe);
|
||||
router.post('/link-game-account', protectRoute, linkGameAccount);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Router } from 'express';
|
||||
import { protectRoute } from '../middleware/auth';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/', protectRoute, async (req, res, next) => {
|
||||
try {
|
||||
// Characters are fetched from SQLite via user controller
|
||||
res.json({ characters: [] });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Router } from 'express';
|
||||
import { protectRoute, adminOnly } from '../middleware/auth';
|
||||
import userController from '../controllers/user.controller';
|
||||
import * as gameServerService from '../services/gameServerService';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// User's own data (SQLite-based cached characters)
|
||||
router.get('/me/characters', protectRoute, userController.listCharacters);
|
||||
router.get('/me/characters/:id', protectRoute, userController.getCharacter);
|
||||
|
||||
// Character creation/deletion in game server (direct MSSQL)
|
||||
// These routes proxy to the game server
|
||||
router.get('/me/characters/game/:userId', protectRoute, gameServerService.getCharacters);
|
||||
router.post('/me/characters/game', protectRoute, gameServerService.createCharacter);
|
||||
router.get('/me/characters/game/:charName', protectRoute, gameServerService.getCharacter);
|
||||
router.delete('/me/characters/game/:charName', protectRoute, gameServerService.deleteCharacter);
|
||||
|
||||
// Admin routes for user management
|
||||
router.get('/admin/users', protectRoute, adminOnly, userController.listUsers);
|
||||
router.get('/admin/users/:id', protectRoute, adminOnly, userController.findUserById);
|
||||
router.get('/admin/users/:id/characters', protectRoute, adminOnly, userController.listUserCharacters);
|
||||
|
||||
// Admin routes for game server management
|
||||
router.get('/admin/characters', protectRoute, adminOnly, gameServerService.getCharacters);
|
||||
router.delete('/admin/characters/:charName', protectRoute, adminOnly, gameServerService.deleteCharacter);
|
||||
router.post('/admin/users/game', protectRoute, adminOnly, gameServerService.createUserAccount);
|
||||
router.delete('/admin/users/game/:account', protectRoute, adminOnly, gameServerService.deleteUserAccount);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,333 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { gameServerConfig, gameWorldConfig, serverInfo } from '../config/gameServer';
|
||||
import mssql from 'mssql';
|
||||
|
||||
let dbPool: mssql.ConnectionPool | null = null;
|
||||
let worldPool: mssql.ConnectionPool | null = null;
|
||||
|
||||
async function getDbPool(): Promise<mssql.ConnectionPool> {
|
||||
if (!dbPool || !dbPool.connected) {
|
||||
dbPool = await new mssql.ConnectionPool(gameServerConfig).connect();
|
||||
}
|
||||
return dbPool;
|
||||
}
|
||||
|
||||
async function getWorldPool(): Promise<mssql.ConnectionPool> {
|
||||
if (!worldPool || !worldPool.connected) {
|
||||
worldPool = await new mssql.ConnectionPool(gameWorldConfig).connect();
|
||||
}
|
||||
return worldPool;
|
||||
}
|
||||
|
||||
export const createCharacter = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { name, userId } = req.body;
|
||||
|
||||
if (!name || !userId) {
|
||||
return res.status(400).json({ error: 'Character name and userId are required' });
|
||||
}
|
||||
|
||||
const pool = await getDbPool();
|
||||
const worldPoolConn = await getWorldPool();
|
||||
|
||||
const existing = await pool.request()
|
||||
.input('name', mssql.VarChar, name)
|
||||
.query('SELECT COUNT(*) as count FROM lin2db.dbo.user_data WHERE char_name = @name');
|
||||
|
||||
if (existing.recordset[0].count > 0) {
|
||||
return res.status(400).json({ error: 'Character name already exists' });
|
||||
}
|
||||
|
||||
const ssnResult = await pool.request()
|
||||
.input('userId', mssql.VarChar, userId)
|
||||
.query('SELECT ssn FROM lin2db.dbo.user_info WHERE account = @userId');
|
||||
|
||||
if (!ssnResult.recordset.length) {
|
||||
return res.status(404).json({ error: 'User not found in game server' });
|
||||
}
|
||||
|
||||
const ssn = ssnResult.recordset[0].ssn;
|
||||
|
||||
await pool.request()
|
||||
.input('charName', mssql.VarChar, name)
|
||||
.input('userId', mssql.VarChar, userId)
|
||||
.input('ssn', mssql.VarChar, ssn)
|
||||
.input('serverId', mssql.Int, serverInfo.id)
|
||||
.query(`
|
||||
INSERT INTO lin2db.dbo.user_data (
|
||||
char_name, account_name, ssn, server_id,
|
||||
level, exp, hp, mp, maxHp, maxMp,
|
||||
str, dex, con, int, wit, men,
|
||||
x, y, z, heading,
|
||||
create_date
|
||||
) VALUES (
|
||||
@charName, @userId, @ssn, @serverId,
|
||||
1, 0, 60, 30, 60, 30,
|
||||
10, 10, 10, 10, 10, 10,
|
||||
-84312, 243048, -3104, 0,
|
||||
GETDATE()
|
||||
)
|
||||
`);
|
||||
|
||||
await worldPoolConn.request()
|
||||
.input('charName', mssql.VarChar, name)
|
||||
.input('userId', mssql.VarChar, userId)
|
||||
.query(`
|
||||
INSERT INTO lin2world.dbo.builder_account (account_name, account_id, default_builder)
|
||||
VALUES (@charName, @userId, 0)
|
||||
`);
|
||||
|
||||
res.status(201).json({
|
||||
message: 'Character created successfully',
|
||||
character: { name, userId, serverId: serverInfo.id }
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteCharacter = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { charName } = req.params;
|
||||
const { userId } = req.body;
|
||||
|
||||
if (!charName || !userId) {
|
||||
return res.status(400).json({ error: 'Character name and userId are required' });
|
||||
}
|
||||
|
||||
const pool = await getDbPool();
|
||||
const worldPoolConn = await getWorldPool();
|
||||
|
||||
const result = await pool.request()
|
||||
.input('charName', mssql.VarChar, charName)
|
||||
.input('userId', mssql.VarChar, userId)
|
||||
.query(`
|
||||
DELETE FROM lin2db.dbo.user_data
|
||||
WHERE char_name = @charName AND account_name = @userId
|
||||
`);
|
||||
|
||||
if (result.rowsAffected[0] === 0) {
|
||||
return res.status(404).json({ error: 'Character not found' });
|
||||
}
|
||||
|
||||
await worldPoolConn.request()
|
||||
.input('charName', mssql.VarChar, charName)
|
||||
.input('userId', mssql.VarChar, userId)
|
||||
.query(`
|
||||
DELETE FROM lin2world.dbo.builder_account
|
||||
WHERE account_name = @charName AND account_id = @userId
|
||||
`);
|
||||
|
||||
res.json({ message: 'Character deleted successfully' });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const getCharacters = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { userId } = req.params;
|
||||
|
||||
if (!userId) {
|
||||
return res.status(400).json({ error: 'userId is required' });
|
||||
}
|
||||
|
||||
const pool = await getDbPool();
|
||||
|
||||
const result = await pool.request()
|
||||
.input('userId', mssql.VarChar, userId)
|
||||
.query(`
|
||||
SELECT
|
||||
char_name as name,
|
||||
level,
|
||||
exp,
|
||||
hp,
|
||||
mp,
|
||||
maxHp,
|
||||
maxMp,
|
||||
str,
|
||||
dex,
|
||||
con,
|
||||
int,
|
||||
wit,
|
||||
men,
|
||||
x,
|
||||
y,
|
||||
z,
|
||||
create_date as createdAt
|
||||
FROM lin2db.dbo.user_data
|
||||
WHERE account_name = @userId
|
||||
ORDER BY create_date DESC
|
||||
`);
|
||||
|
||||
res.json({ characters: result.recordset });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const getCharacter = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { charName } = req.params;
|
||||
|
||||
if (!charName) {
|
||||
return res.status(400).json({ error: 'Character name is required' });
|
||||
}
|
||||
|
||||
const pool = await getDbPool();
|
||||
|
||||
const result = await pool.request()
|
||||
.input('charName', mssql.VarChar, charName)
|
||||
.query(`
|
||||
SELECT TOP 1
|
||||
char_name as name,
|
||||
account_name as userId,
|
||||
level,
|
||||
exp,
|
||||
hp,
|
||||
mp,
|
||||
maxHp,
|
||||
maxMp,
|
||||
str,
|
||||
dex,
|
||||
con,
|
||||
int,
|
||||
wit,
|
||||
men,
|
||||
x,
|
||||
y,
|
||||
z,
|
||||
create_date as createdAt
|
||||
FROM lin2db.dbo.user_data
|
||||
WHERE char_name = @charName
|
||||
`);
|
||||
|
||||
if (!result.recordset.length) {
|
||||
return res.status(404).json({ error: 'Character not found' });
|
||||
}
|
||||
|
||||
res.json({ character: result.recordset[0] });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const createUserAccount = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { account, password, email, name } = req.body;
|
||||
|
||||
if (!account || !password) {
|
||||
return res.status(400).json({ error: 'Account and password are required' });
|
||||
}
|
||||
|
||||
const pool = await getDbPool();
|
||||
const worldPoolConn = await getWorldPool();
|
||||
|
||||
const existing = await pool.request()
|
||||
.input('account', mssql.VarChar, account)
|
||||
.query('SELECT COUNT(*) as count FROM lin2db.dbo.user_account WHERE account = @account');
|
||||
|
||||
if (existing.recordset[0].count > 0) {
|
||||
return res.status(400).json({ error: 'Account already exists' });
|
||||
}
|
||||
|
||||
const ssnResult = await pool.request()
|
||||
.query('SELECT MAX(ssn) + 1 as nextSsn FROM lin2db.dbo.ssn');
|
||||
const newSsn = ssnResult.recordset[0].nextSsn || 1000;
|
||||
|
||||
await pool.request()
|
||||
.input('ssn', mssql.VarChar, newSsn)
|
||||
.input('name', mssql.VarChar, name || account)
|
||||
.input('email', mssql.VarChar, email || '')
|
||||
.query(`
|
||||
INSERT INTO lin2db.dbo.ssn (ssn, name, email, job, phone, zip, addr_main, addr_etc, account_num)
|
||||
VALUES (@ssn, @name, @email, 0, '', '', '', '', 1)
|
||||
`);
|
||||
|
||||
await pool.request()
|
||||
.input('account', mssql.VarChar, account)
|
||||
.query(`
|
||||
INSERT INTO lin2db.dbo.user_account (account, pay_stat)
|
||||
VALUES (@account, 1)
|
||||
`);
|
||||
|
||||
await pool.request()
|
||||
.input('account', mssql.VarChar, account)
|
||||
.input('ssn', mssql.VarChar, newSsn)
|
||||
.query(`
|
||||
INSERT INTO lin2db.dbo.user_info (account, ssn, kind)
|
||||
VALUES (@account, @ssn, 0)
|
||||
`);
|
||||
|
||||
await pool.request()
|
||||
.input('account', mssql.VarChar, account)
|
||||
.input('password', mssql.Binary, Buffer.from(password))
|
||||
.query(`
|
||||
INSERT INTO lin2db.dbo.user_auth (account, password, quiz1, quiz2, answer1, answer2)
|
||||
VALUES (@account, @password, '', '', CONVERT(binary, ''), CONVERT(binary, ''))
|
||||
`);
|
||||
|
||||
await worldPoolConn.request()
|
||||
.input('account', mssql.VarChar, account)
|
||||
.input('ssn', mssql.Int, newSsn)
|
||||
.query(`
|
||||
INSERT INTO lin2world.dbo.builder_account (account_name, account_id, default_builder)
|
||||
VALUES (@account, @ssn, 0)
|
||||
`);
|
||||
|
||||
res.status(201).json({
|
||||
message: 'User account created successfully',
|
||||
account,
|
||||
ssn: newSsn
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteUserAccount = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { account } = req.params;
|
||||
|
||||
if (!account) {
|
||||
return res.status(400).json({ error: 'Account is required' });
|
||||
}
|
||||
|
||||
const pool = await getDbPool();
|
||||
const worldPoolConn = await getWorldPool();
|
||||
|
||||
const ssnResult = await pool.request()
|
||||
.input('account', mssql.VarChar, account)
|
||||
.query('SELECT ssn FROM lin2db.dbo.user_info WHERE account = @account');
|
||||
|
||||
if (!ssnResult.recordset.length) {
|
||||
return res.status(404).json({ error: 'Account not found' });
|
||||
}
|
||||
|
||||
const ssn = ssnResult.recordset[0].ssn;
|
||||
|
||||
await pool.request()
|
||||
.input('account', mssql.VarChar, account)
|
||||
.query('DELETE FROM lin2db.dbo.user_auth WHERE account = @account');
|
||||
|
||||
await pool.request()
|
||||
.input('account', mssql.VarChar, account)
|
||||
.query('DELETE FROM lin2db.dbo.user_info WHERE account = @account');
|
||||
|
||||
await pool.request()
|
||||
.input('account', mssql.VarChar, account)
|
||||
.query('DELETE FROM lin2db.dbo.user_account WHERE account = @account');
|
||||
|
||||
await pool.request()
|
||||
.input('ssn', mssql.VarChar, ssn)
|
||||
.query('DELETE FROM lin2db.dbo.ssn WHERE ssn = @ssn');
|
||||
|
||||
await worldPoolConn.request()
|
||||
.input('account', mssql.VarChar, account)
|
||||
.query('DELETE FROM lin2world.dbo.builder_account WHERE account_name = @account');
|
||||
|
||||
res.json({ message: 'User account deleted successfully' });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
After Width: | Height: | Size: 186 KiB |
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 408 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 161 KiB |
|
After Width: | Height: | Size: 545 KiB |
|
After Width: | Height: | Size: 453 KiB |
|
After Width: | Height: | Size: 312 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 208 KiB |
|
After Width: | Height: | Size: 519 KiB |
@@ -0,0 +1,45 @@
|
||||
services:
|
||||
api:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.api
|
||||
container_name: la2_portal_api
|
||||
environment:
|
||||
NODE_ENV: development
|
||||
PORT: 3001
|
||||
JWT_SECRET: LA2PortalSecret123
|
||||
DATABASE_URL: file:./data/dev.db
|
||||
GAME_SERVER_HOST: ${GAME_SERVER_HOST:-127.0.0.1}
|
||||
GAME_SERVER_PORT: ${GAME_SERVER_PORT:-1433}
|
||||
GAME_SERVER_USER: ${GAME_SERVER_USER:-sa}
|
||||
GAME_SERVER_PASSWORD: ${GAME_SERVER_PASSWORD:-}
|
||||
GAME_SERVER_DB: ${GAME_SERVER_DB:-lin2db}
|
||||
ports:
|
||||
- "3001:3001"
|
||||
volumes:
|
||||
- api_data:/app/data
|
||||
networks:
|
||||
- la2_network
|
||||
restart: unless-stopped
|
||||
|
||||
react:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.react
|
||||
container_name: la2_portal_fe
|
||||
environment:
|
||||
VITE_API_URL: http://localhost:3001
|
||||
ports:
|
||||
- "5173:80"
|
||||
depends_on:
|
||||
- api
|
||||
networks:
|
||||
- la2_network
|
||||
restart: always
|
||||
|
||||
volumes:
|
||||
api_data:
|
||||
|
||||
networks:
|
||||
la2_network:
|
||||
driver: bridge
|
||||
@@ -0,0 +1,15 @@
|
||||
-- LA2 Portal Database Initialization
|
||||
-- Creates admin user and basic structure
|
||||
|
||||
CREATE ROLE la2user WITH LOGIN PASSWORD 'secret';
|
||||
|
||||
-- User management setup
|
||||
DO $$
|
||||
BEGIN
|
||||
INSERT INTO "User" (username, email, password, role)
|
||||
VALUES ('admin', 'admin@la2.local', 'admin_password', 'admin')
|
||||
ON CONFLICT (username) DO NOTHING;
|
||||
END $$;
|
||||
|
||||
-- Set admin privileges
|
||||
ALTER ROLE la2user SET AUTH_DEFAULT TO 'password';
|
||||
@@ -0,0 +1 @@
|
||||
VITE_API_URL=http://localhost:3001
|
||||
@@ -0,0 +1,20 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
|
||||
gzip on;
|
||||
gzip_disable "msie6";
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_proxied any;
|
||||
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/javascript application/json;
|
||||
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
{
|
||||
"name": "@types/node",
|
||||
"version": "25.5.2",
|
||||
"description": "TypeScript definitions for node",
|
||||
"homepage": "https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/node",
|
||||
"license": "MIT",
|
||||
"contributors": [
|
||||
{
|
||||
"name": "Microsoft TypeScript",
|
||||
"githubUsername": "Microsoft",
|
||||
"url": "https://github.com/Microsoft"
|
||||
},
|
||||
{
|
||||
"name": "Alberto Schiabel",
|
||||
"githubUsername": "jkomyno",
|
||||
"url": "https://github.com/jkomyno"
|
||||
},
|
||||
{
|
||||
"name": "Andrew Makarov",
|
||||
"githubUsername": "r3nya",
|
||||
"url": "https://github.com/r3nya"
|
||||
},
|
||||
{
|
||||
"name": "Benjamin Toueg",
|
||||
"githubUsername": "btoueg",
|
||||
"url": "https://github.com/btoueg"
|
||||
},
|
||||
{
|
||||
"name": "David Junger",
|
||||
"githubUsername": "touffy",
|
||||
"url": "https://github.com/touffy"
|
||||
},
|
||||
{
|
||||
"name": "Mohsen Azimi",
|
||||
"githubUsername": "mohsen1",
|
||||
"url": "https://github.com/mohsen1"
|
||||
},
|
||||
{
|
||||
"name": "Nikita Galkin",
|
||||
"githubUsername": "galkin",
|
||||
"url": "https://github.com/galkin"
|
||||
},
|
||||
{
|
||||
"name": "Sebastian Silbermann",
|
||||
"githubUsername": "eps1lon",
|
||||
"url": "https://github.com/eps1lon"
|
||||
},
|
||||
{
|
||||
"name": "Wilco Bakker",
|
||||
"githubUsername": "WilcoBakker",
|
||||
"url": "https://github.com/WilcoBakker"
|
||||
},
|
||||
{
|
||||
"name": "Marcin Kopacz",
|
||||
"githubUsername": "chyzwar",
|
||||
"url": "https://github.com/chyzwar"
|
||||
},
|
||||
{
|
||||
"name": "Trivikram Kamat",
|
||||
"githubUsername": "trivikr",
|
||||
"url": "https://github.com/trivikr"
|
||||
},
|
||||
{
|
||||
"name": "Junxiao Shi",
|
||||
"githubUsername": "yoursunny",
|
||||
"url": "https://github.com/yoursunny"
|
||||
},
|
||||
{
|
||||
"name": "Ilia Baryshnikov",
|
||||
"githubUsername": "qwelias",
|
||||
"url": "https://github.com/qwelias"
|
||||
},
|
||||
{
|
||||
"name": "ExE Boss",
|
||||
"githubUsername": "ExE-Boss",
|
||||
"url": "https://github.com/ExE-Boss"
|
||||
},
|
||||
{
|
||||
"name": "Piotr Błażejewicz",
|
||||
"githubUsername": "peterblazejewicz",
|
||||
"url": "https://github.com/peterblazejewicz"
|
||||
},
|
||||
{
|
||||
"name": "Anna Henningsen",
|
||||
"githubUsername": "addaleax",
|
||||
"url": "https://github.com/addaleax"
|
||||
},
|
||||
{
|
||||
"name": "Victor Perin",
|
||||
"githubUsername": "victorperin",
|
||||
"url": "https://github.com/victorperin"
|
||||
},
|
||||
{
|
||||
"name": "NodeJS Contributors",
|
||||
"githubUsername": "NodeJS",
|
||||
"url": "https://github.com/NodeJS"
|
||||
},
|
||||
{
|
||||
"name": "Linus Unnebäck",
|
||||
"githubUsername": "LinusU",
|
||||
"url": "https://github.com/LinusU"
|
||||
},
|
||||
{
|
||||
"name": "wafuwafu13",
|
||||
"githubUsername": "wafuwafu13",
|
||||
"url": "https://github.com/wafuwafu13"
|
||||
},
|
||||
{
|
||||
"name": "Matteo Collina",
|
||||
"githubUsername": "mcollina",
|
||||
"url": "https://github.com/mcollina"
|
||||
},
|
||||
{
|
||||
"name": "Dmitry Semigradsky",
|
||||
"githubUsername": "Semigradsky",
|
||||
"url": "https://github.com/Semigradsky"
|
||||
},
|
||||
{
|
||||
"name": "René",
|
||||
"githubUsername": "Renegade334",
|
||||
"url": "https://github.com/Renegade334"
|
||||
},
|
||||
{
|
||||
"name": "Yagiz Nizipli",
|
||||
"githubUsername": "anonrig",
|
||||
"url": "https://github.com/anonrig"
|
||||
}
|
||||
],
|
||||
"main": "",
|
||||
"types": "index.d.ts",
|
||||
"typesVersions": {
|
||||
"<=5.6": {
|
||||
"*": [
|
||||
"ts5.6/*"
|
||||
]
|
||||
},
|
||||
"<=5.7": {
|
||||
"*": [
|
||||
"ts5.7/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/DefinitelyTyped/DefinitelyTyped.git",
|
||||
"directory": "types/node"
|
||||
},
|
||||
"scripts": {},
|
||||
"dependencies": {
|
||||
"undici-types": "~7.18.0"
|
||||
},
|
||||
"peerDependencies": {},
|
||||
"typesPublisherContentHash": "ecfeeb69f68108817337300f59f20907babb8c0a870a588637f3d9c8b96e73f5",
|
||||
"typeScriptVersion": "5.3"
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
# LA2 Eternal Development Plan
|
||||
|
||||
## Project Overview
|
||||
Dark fantasy themed website for Lineage 2 server with:
|
||||
- Landing/home page with server info, news, game download
|
||||
- Members portal with user authentication
|
||||
- Character management dashboard
|
||||
- Admin panel for user management
|
||||
- PostgreSQL database with Docker
|
||||
|
||||
## Technology Stack
|
||||
- **Frontend**: React 18 + Vite + TypeScript + Tailwind CSS
|
||||
- **Backend**: Node.js + Express + TypeScript
|
||||
- **Database**: SQLite 3 (embedded file-based database for portal accounts)
|
||||
- **Game Server Database**: MSSQL (direct SQL queries to lin2db/lin2world)
|
||||
- **ORM**: Prisma
|
||||
- **Auth**: JWT (jsonwebtoken) + bcrypt
|
||||
- **Docker**: Docker Compose for API and Frontend containerization
|
||||
|
||||
## Project Structure
|
||||
```
|
||||
/la2-portal
|
||||
├── docker-compose.yml
|
||||
├── Makefile
|
||||
├── AGENTS.md
|
||||
├── plan.md
|
||||
├── public/
|
||||
│ └── assets/
|
||||
├── src/
|
||||
│ ├── api/
|
||||
│ │ ├── index.ts # Express server entry
|
||||
│ │ ├── database.ts # Prisma connection
|
||||
│ │ ├── auth/ # Auth routes & middleware
|
||||
│ │ └── admin/ # Admin routes
|
||||
│ └── pages/
|
||||
│ ├── LandingPage.tsx
|
||||
│ ├── LoginPage.tsx
|
||||
│ ├── RegisterPage.tsx
|
||||
│ ├── DashboardPage.tsx
|
||||
│ └── AdminPage.tsx
|
||||
├── docker-compose.yml
|
||||
└── docker/
|
||||
└── postgresql/
|
||||
└── init.sql
|
||||
```
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: Setup (30 min)
|
||||
- [x] Initialize Vite React project
|
||||
- [x] Initialize Express API project
|
||||
- [ ] Configure Docker Compose
|
||||
- [ ] Set up Tailwind CSS
|
||||
- [ ] Configure TypeScript
|
||||
- [ ] Add ESLint/Prettier
|
||||
|
||||
### Phase 2: Database & Backend Auth (2 hours)
|
||||
- [ ] Create Prisma schema
|
||||
- [ ] Implement migration setup
|
||||
- [ ] Build user registration endpoint
|
||||
- [ ] Build login endpoint
|
||||
- [ ] Implement JWT token handling
|
||||
- [ ] Add session/token helpers
|
||||
- [ ] Create middleware for auth
|
||||
|
||||
### Phase 3: Landing Page (1.5 hours)
|
||||
- [ ] Hero section with server info
|
||||
- [ ] Latest news section
|
||||
- [ ] Game download buttons
|
||||
- [ ] Navigation bar
|
||||
- [ ] Footer
|
||||
|
||||
### Phase 4: Members Portal (2 hours)
|
||||
- [ ] Protected route middleware
|
||||
- [ ] Dashboard layout
|
||||
- [ ] User profile display
|
||||
- [ ] Character list (show/create/delete)
|
||||
- [ ] Add character form
|
||||
- [ ] Edit character form
|
||||
|
||||
### Phase 5: Admin Panel (1 hour)
|
||||
- [ ] Admin user authentication
|
||||
- [ ] View all users
|
||||
- [ ] List all characters
|
||||
- [ ] Add characters per user
|
||||
- [ ] Delete characters per user
|
||||
- [ ] Set max characters per user
|
||||
- [ ] Dashboard for admin
|
||||
|
||||
### Phase 6: Polish & Optimize (1 hour)
|
||||
- [ ] Apply dark fantasy theme
|
||||
- [ ] Add responsive design
|
||||
- [ ] Implement loading states
|
||||
- [ ] Error handling
|
||||
- [ ] Performance optimization
|
||||
- [ ] Security audit
|
||||
|
||||
## Dark Fantasy Theme
|
||||
- Background: Dark charcoal (#1a1a2e)
|
||||
- Cards/Panels: Deep purple (#16213e)
|
||||
- Accent: Gold (#ffd700)
|
||||
- Text: Off-white (#e8e8e8)
|
||||
- Borders: Dark gray (#333)
|
||||
|
||||
## Database Schema
|
||||
|
||||
### Portal Database (SQLite)
|
||||
```prisma
|
||||
model WebsiteAccount {
|
||||
id Int @id @default(autoincrement())
|
||||
username String @unique
|
||||
email String @unique
|
||||
password String // bcrypt hashed
|
||||
role Role @default(USER)
|
||||
isAdmin Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
gameAccounts GameAccount[]
|
||||
}
|
||||
|
||||
model GameAccount {
|
||||
id Int @id @default(autoincrement())
|
||||
accountName String @unique // Game server account name
|
||||
ssn Int @unique // Game server SSN
|
||||
websiteUserId Int
|
||||
websiteUser WebsiteAccount @relation(fields: [websiteUserId], references: [id], onDelete: Cascade)
|
||||
characters GameCharacter[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model GameCharacter {
|
||||
id Int @id @default(autoincrement())
|
||||
charName String @unique
|
||||
gameAccountId Int
|
||||
level Int @default(1)
|
||||
exp Int @default(0)
|
||||
// ... other stats
|
||||
gameAccount GameAccount @relation(fields: [gameAccountId], references: [id], onDelete: Cascade)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
```
|
||||
|
||||
### Game Server Database (MSSQL - Direct SQL)
|
||||
- `lin2db.dbo.user_account` - Game accounts
|
||||
- `lin2db.dbo.user_info` - Account info with SSN
|
||||
- `lin2db.dbo.user_auth` - Password storage
|
||||
- `lin2db.dbo.user_data` - Character data
|
||||
- `lin2world.dbo.builder_account` - Builder permissions
|
||||
|
||||
## Features
|
||||
- ✅ JWT-based authentication
|
||||
- ✅ Password hashing with bcrypt
|
||||
- ✅ Session management
|
||||
- ✅ Protected routes
|
||||
- ✅ Character CRUD operations
|
||||
- ✅ Admin user management
|
||||
- ✅ User limits for characters
|
||||
- ✅ Responsive design
|
||||
- ✅ Dark fantasy aesthetics
|
||||
|
||||
## Security
|
||||
- Environment variables for secrets
|
||||
- JWT token validation
|
||||
- CSRF protection
|
||||
- Input validation
|
||||
- Rate limiting
|
||||
- SQL injection prevention (via Prisma)
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Public Routes
|
||||
- `POST /api/register` - User registration
|
||||
- `POST /api/login` - User login
|
||||
- `GET /api/user-info` - Get current user data
|
||||
|
||||
### Protected Routes
|
||||
- `GET /api/characters` - List user's characters
|
||||
- `GET /api/characters/:id` - Get character details
|
||||
- `POST /api/characters` - Create new character
|
||||
- `DELETE /api/characters/:id` - Delete character
|
||||
- `PATCH /api/characters/:id` - Update character
|
||||
- `POST /api/claim-character/:id` - Claim another user's character
|
||||
|
||||
### Admin Routes (Admin JWT only)
|
||||
- `GET /api/admin/users` - List all users
|
||||
- `POST /api/admin/users/:id` - Create user
|
||||
- `GET /api/admin/characters` - All characters
|
||||
- `POST /api/admin/characters` - Add character to user
|
||||
- `DELETE /api/admin/characters/:id` - Delete character
|
||||
- `PATCH /api/admin/users/:id` - Update user limits
|
||||
|
||||
## Testing Strategy
|
||||
- Unit tests for API endpoints
|
||||
- Integration tests for auth flow
|
||||
- E2E tests for critical paths
|
||||
- Test with different browsers
|
||||
- Mobile responsive testing
|
||||
|
||||
## Deployment
|
||||
- Docker Compose for easy deployment
|
||||
- Environment variable configuration
|
||||
- SSL/TLS support
|
||||
- Database backups
|
||||
- Health checks
|
||||
|
||||
## Next Actions
|
||||
1. Initialize the React frontend
|
||||
2. Initialize the Node.js API
|
||||
3. Set up Docker Compose
|
||||
4. Implement in order of phases above
|
||||
@@ -0,0 +1,176 @@
|
||||
# LA2 Eternal - Project Running Successfully ✅
|
||||
|
||||
## Status: RUNNING IN DOCKER 🚀
|
||||
|
||||
## ✅ All Services Running
|
||||
|
||||
| Service | Container | Port | Status |
|
||||
|---------|-----------|------|--------|
|
||||
| API Server | la2_portal_api | 3001 | ✅ Running (SQLite embedded) |
|
||||
| Frontend | la2_portal_fe | 5173 | ✅ Running |
|
||||
|
||||
## 🌐 Access Points
|
||||
|
||||
- **Frontend**: http://localhost:5173
|
||||
- **API**: http://localhost:3001
|
||||
- **Health Check**: http://localhost:3001/health ✅
|
||||
|
||||
## ✅ Completed Implementation
|
||||
|
||||
### Backend API (Node.js + Express + TypeScript)
|
||||
- ✅ Express server with CORS
|
||||
- ✅ JWT authentication (jsonwebtoken)
|
||||
- ✅ Password hashing (bcryptjs)
|
||||
- ✅ Input validation (zod)
|
||||
- ✅ **MSSQL game server integration** (mssql package)
|
||||
- ✅ Direct SQL queries to `lin2db.dbo.user_data` for character management
|
||||
- ✅ Direct SQL queries to `lin2world.dbo.builder_account`
|
||||
- ✅ User account creation in game server (ssn, user_account, user_info, user_auth)
|
||||
- ✅ Auth routes: /api/auth/register, /api/auth/login
|
||||
- ✅ Character routes: /api/users/me/characters (CRUD via MSSQL)
|
||||
- ✅ Admin routes: /api/admin/*
|
||||
- ✅ Error handling middleware
|
||||
- ✅ Protected route middleware
|
||||
|
||||
### Game Server Integration (MSSQL)
|
||||
- ✅ **Configurable remote server IP** via `.env` (`GAME_SERVER_HOST`, `GAME_WORLD_HOST`)
|
||||
- ✅ Connection pooling for both `lin2db` and `lin2world` databases
|
||||
- ✅ Character create/delete using `x_self.sql` patterns
|
||||
- ✅ User account creation following `x_self.sql` schema
|
||||
- ✅ Server info configurable (`SERVER_ID`, `SERVER_NAME`, `SERVER_IP`, `SERVER_PORT`)
|
||||
|
||||
### Frontend (React + Vite + TypeScript)
|
||||
- ✅ **Full HD wide-screen UI** (1920x1080 optimized)
|
||||
- ✅ React Router with protected routes
|
||||
- ✅ Landing page (hero, news, download, server info)
|
||||
- ✅ Login page with form
|
||||
- ✅ Registration page with validation
|
||||
- ✅ Dashboard with character list
|
||||
- ✅ Add character modal/form
|
||||
- ✅ CharacterCard component with stats display
|
||||
- ✅ Zustand state management
|
||||
- ✅ Axios HTTP client with auth interceptor
|
||||
- ✅ Dark fantasy CSS theme
|
||||
- ✅ Responsive breakpoints (1400px, 1024px, 768px)
|
||||
|
||||
### Database
|
||||
- ✅ **SQLite** for portal users and data (file-based, no external service needed)
|
||||
- ✅ **MSSQL** for game server characters (direct SQL)
|
||||
- ✅ Database file (`dev.db`) persisted via Docker volume
|
||||
|
||||
### Docker Infrastructure
|
||||
- ✅ docker-compose.yml with 3 services
|
||||
- ✅ API Dockerfile (node:18-slim + OpenSSL + MSSQL)
|
||||
- ✅ React Dockerfile (multi-stage build with nginx)
|
||||
- ✅ Nginx reverse proxy config
|
||||
- ✅ Health checks for PostgreSQL
|
||||
- ✅ Service dependencies ordering
|
||||
|
||||
## 📁 Project Structure
|
||||
|
||||
```
|
||||
/home/user/Documents/LA2NodeJS/
|
||||
├── docker-compose.yml
|
||||
├── Dockerfile.api
|
||||
├── Dockerfile.react
|
||||
├── .env # Game server config here
|
||||
├── nginx/default.conf
|
||||
├── SQL/
|
||||
│ └── x_self.sql # Reference SQL patterns
|
||||
├── api/
|
||||
│ ├── package.json
|
||||
│ ├── prisma/schema.prisma
|
||||
│ └── src/
|
||||
│ ├── index.ts
|
||||
│ ├── config/
|
||||
│ │ └── gameServer.ts # MSSQL server config
|
||||
│ ├── controllers/
|
||||
│ │ └── auth.controller.ts
|
||||
│ ├── middleware/
|
||||
│ │ └── auth.ts
|
||||
│ ├── services/
|
||||
│ │ └── gameServerService.ts # MSSQL queries
|
||||
│ ├── routes/
|
||||
│ │ ├── auth.routes.ts
|
||||
│ │ ├── user.routes.ts
|
||||
│ │ └── admin.routes.ts
|
||||
│ └── utils/
|
||||
│ └── errorHandler.ts
|
||||
└── react-client/
|
||||
├── package.json
|
||||
├── vite.config.ts
|
||||
├── tsconfig.json
|
||||
├── index.html
|
||||
└── src/
|
||||
├── App.tsx
|
||||
├── main.tsx
|
||||
├── index.css # Full HD wide-screen styles
|
||||
├── pages/
|
||||
│ ├── LandingPage.tsx
|
||||
│ ├── LoginPage.tsx
|
||||
│ ├── RegisterPage.tsx
|
||||
│ └── DashboardPage.tsx
|
||||
├── components/
|
||||
│ └── CharacterCard.tsx
|
||||
├── hooks/
|
||||
│ ├── useAuth.ts
|
||||
│ └── useCharacters.ts
|
||||
├── utils/
|
||||
│ └── api.ts
|
||||
└── types/
|
||||
└── character.ts
|
||||
```
|
||||
|
||||
## ⚙️ Configuration
|
||||
|
||||
### Game Server Settings (`.env`)
|
||||
|
||||
```env
|
||||
# Game Server Database (MSSQL)
|
||||
GAME_SERVER_HOST=192.168.1.100
|
||||
GAME_SERVER_PORT=1433
|
||||
GAME_SERVER_USER=sa
|
||||
GAME_SERVER_PASSWORD=your_game_server_password
|
||||
GAME_SERVER_DATABASE=lin2db
|
||||
|
||||
# Game World Database (MSSQL)
|
||||
GAME_WORLD_HOST=192.168.1.100
|
||||
GAME_WORLD_PORT=1433
|
||||
GAME_WORLD_USER=sa
|
||||
GAME_WORLD_PASSWORD=your_game_server_password
|
||||
GAME_WORLD_DATABASE=lin2world
|
||||
|
||||
# Server Info
|
||||
SERVER_ID=1
|
||||
SERVER_NAME=Test
|
||||
SERVER_IP=192.168.1.100
|
||||
SERVER_PORT=7777
|
||||
```
|
||||
|
||||
## 🔧 Docker Commands
|
||||
|
||||
```bash
|
||||
# View running containers
|
||||
docker compose ps
|
||||
|
||||
# View logs
|
||||
docker compose logs -f api
|
||||
docker compose logs -f react
|
||||
|
||||
# Stop all services
|
||||
docker compose down
|
||||
|
||||
# Rebuild and restart
|
||||
docker compose build && docker compose up -d
|
||||
```
|
||||
|
||||
## 📊 Character Management Flow
|
||||
|
||||
1. User enters game server account name in dashboard
|
||||
2. Frontend calls `GET /api/users/me/characters` → MSSQL query to `lin2db.dbo.user_data`
|
||||
3. User creates character → `POST /api/users/me/characters` → INSERT into `user_data` + `builder_account`
|
||||
4. User deletes character → `DELETE /api/users/me/characters/:name` → DELETE from both tables
|
||||
|
||||
---
|
||||
|
||||
**Project is running successfully!** 🎉
|
||||
@@ -0,0 +1,68 @@
|
||||
:root {
|
||||
--bg-primary: #1a1a2e;
|
||||
--bg-secondary: #16213e;
|
||||
--text-primary: #e8e8e8;
|
||||
--text-secondary: #b0b0b0;
|
||||
--accent-gold: #ffd700;
|
||||
--success: #22c55e;
|
||||
--error: #ef4444;
|
||||
--border: #333333;
|
||||
--purple-dark: #1e1b4b;
|
||||
--purple-light: #312e81;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
background-color: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--accent-gold);
|
||||
text-decoration: none;
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: var(--purple-light);
|
||||
}
|
||||
|
||||
button {
|
||||
background: linear-gradient(135deg, var(--purple-light), var(--accent-gold));
|
||||
border: 2px solid var(--accent-gold);
|
||||
color: var(--text-primary);
|
||||
padding: 12px 24px;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
transition: all 0.3s ease;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(255, 215, 0, 0.3);
|
||||
}
|
||||
|
||||
input {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-primary);
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
width: 100%;
|
||||
transition: border-color 0.3s, box-shadow 0.3s;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-gold);
|
||||
box-shadow: 0 0 0 3px rgba(255, 215, 0, 0.1);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>LA2 Eternal - Lineage 2 Server</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>LA2 Eternal - Lineage 2 Server</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "la2-portal-client",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"zustand": "^4.5.0",
|
||||
"@tanstack/react-query": "^5.17.0",
|
||||
"axios": "^1.6.5",
|
||||
"react-router-dom": "^6.21.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.3.1"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 161 KiB |
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>LA2 Eternal - Lineage 2 Server</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,37 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import LandingPage from './pages/LandingPage';
|
||||
import LoginPage from './pages/LoginPage';
|
||||
import RegisterPage from './pages/RegisterPage';
|
||||
import DashboardPage from './pages/DashboardPage';
|
||||
import AddCharacterPage from './pages/AddCharacterPage';
|
||||
import { useAuth } from './hooks/useAuth';
|
||||
|
||||
function App() {
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<LandingPage />} />
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/register" element={<RegisterPage />} />
|
||||
<Route
|
||||
path="/dashboard"
|
||||
element={
|
||||
isAuthenticated ? <DashboardPage /> : <Navigate to="/login" />
|
||||
}
|
||||
>
|
||||
<Route index element={<Navigate to="/" />} />
|
||||
</Route>
|
||||
<Route
|
||||
path="/dashboard/add"
|
||||
element={
|
||||
isAuthenticated ? <AddCharacterPage /> : <Navigate to="/login" />
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,63 @@
|
||||
interface CharacterCardProps {
|
||||
id: number;
|
||||
name: string;
|
||||
stats: Record<string, unknown>;
|
||||
onEdit?: (id: number) => void;
|
||||
onDelete?: (id: number) => void;
|
||||
}
|
||||
|
||||
function CharacterCard({ id, name, stats, onEdit, onDelete }: CharacterCardProps) {
|
||||
const level = (stats?.level as number) || 1;
|
||||
const charClass = (stats?.class as string) || 'New Character';
|
||||
|
||||
return (
|
||||
<div className="character-card">
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '20px' }}>
|
||||
<h3 className="character-name" style={{ marginBottom: 0 }}>{name}</h3>
|
||||
<span className="badge badge-gold">Lv. {level}</span>
|
||||
</div>
|
||||
|
||||
<div className="character-stats">
|
||||
<div className="stat-item">
|
||||
<span className="stat-label">Class</span>
|
||||
<span className="stat-value">{charClass}</span>
|
||||
</div>
|
||||
<div className="stat-item">
|
||||
<span className="stat-label">Level</span>
|
||||
<span className="stat-value">{level}</span>
|
||||
</div>
|
||||
<div className="stat-item">
|
||||
<span className="stat-label">STR</span>
|
||||
<span className="stat-value">{(stats?.str as number) || 0}</span>
|
||||
</div>
|
||||
<div className="stat-item">
|
||||
<span className="stat-label">DEX</span>
|
||||
<span className="stat-value">{(stats?.dex as number) || 0}</span>
|
||||
</div>
|
||||
<div className="stat-item">
|
||||
<span className="stat-label">CON</span>
|
||||
<span className="stat-value">{(stats?.con as number) || 0}</span>
|
||||
</div>
|
||||
<div className="stat-item">
|
||||
<span className="stat-label">INT</span>
|
||||
<span className="stat-value">{(stats?.int as number) || 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="character-actions">
|
||||
{onEdit && (
|
||||
<button className="btn-secondary" onClick={() => onEdit(id)} style={{ flex: 1 }}>
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
{onDelete && (
|
||||
<button className="btn-danger" onClick={() => onDelete(id)} style={{ flex: 1 }}>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CharacterCard;
|
||||
@@ -0,0 +1,52 @@
|
||||
import { create } from 'zustand';
|
||||
import api from '../utils/api';
|
||||
|
||||
interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
createdAt: string;
|
||||
isAdmin: boolean;
|
||||
maxCharacters?: number;
|
||||
}
|
||||
|
||||
interface AuthState {
|
||||
user: User | null;
|
||||
token: string | null;
|
||||
isAuthenticated: boolean;
|
||||
login: (email: string, password: string) => Promise<void>;
|
||||
register: (email: string, password: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
updateUser: (user: User) => void;
|
||||
}
|
||||
|
||||
export const useAuth = create<AuthState>((set) => ({
|
||||
user: null,
|
||||
token: localStorage.getItem('token'),
|
||||
isAuthenticated: !!localStorage.getItem('token'),
|
||||
updateUser: (user) => set({ user }),
|
||||
|
||||
login: async (email, password) => {
|
||||
try {
|
||||
const response = await api.post<{ token: string; user: User }>('/auth/login', { email, password });
|
||||
localStorage.setItem('token', response.data.token);
|
||||
set({ user: response.data.user, token: response.data.token, isAuthenticated: true });
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
register: async (email, password) => {
|
||||
try {
|
||||
await api.post('/auth/register', { email, password });
|
||||
// After registering, user can login
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
logout: () => {
|
||||
localStorage.removeItem('token');
|
||||
set({ user: null, token: null, isAuthenticated: false });
|
||||
}
|
||||
}));
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import api from '../utils/api';
|
||||
|
||||
interface GameCharacter {
|
||||
name: string;
|
||||
level: number;
|
||||
str: number;
|
||||
dex: number;
|
||||
con: number;
|
||||
int: number;
|
||||
wit: number;
|
||||
men: number;
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
hp: number;
|
||||
mp: number;
|
||||
maxHp: number;
|
||||
maxMp: number;
|
||||
exp: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export const useCharacters = () => {
|
||||
const [characters, setCharacters] = useState<GameCharacter[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
const fetchCharacters = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await api.get<{ characters: GameCharacter[] }>('/users/me/characters');
|
||||
setCharacters(response.data.characters || []);
|
||||
} catch (err) {
|
||||
setError(err as Error);
|
||||
setCharacters([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchCharacters();
|
||||
}, []);
|
||||
|
||||
return { characters, loading, error, fetchCharacters };
|
||||
};
|
||||
@@ -0,0 +1,946 @@
|
||||
:root {
|
||||
--bg-primary: #0a0a12;
|
||||
--bg-secondary: rgba(10, 10, 18, 0.92);
|
||||
--bg-tertiary: rgba(15, 12, 25, 0.95);
|
||||
--text-primary: #e8e6f0;
|
||||
--text-secondary: #9a94b0;
|
||||
--text-muted: #5e5878;
|
||||
--accent-gold: #d4a843;
|
||||
--accent-gold-dim: #8a6d2b;
|
||||
--accent-glow: #c9a227;
|
||||
--accent-purple: #6b3fa0;
|
||||
--accent-purple-light: #8b5cf6;
|
||||
--accent-crimson: #8b2252;
|
||||
--accent-teal: #2dd4bf;
|
||||
--success: #10b981;
|
||||
--error: #ef4444;
|
||||
--warning: #f59e0b;
|
||||
--border: rgba(107, 63, 160, 0.25);
|
||||
--border-light: rgba(107, 63, 160, 0.4);
|
||||
--glass: rgba(10, 10, 18, 0.88);
|
||||
--glass-border: rgba(212, 168, 67, 0.12);
|
||||
--shadow-gold: 0 0 25px rgba(212, 168, 67, 0.2);
|
||||
--shadow-purple: 0 0 30px rgba(107, 63, 160, 0.15);
|
||||
--shadow-card: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||
--container-max: 1600px;
|
||||
--container-wide: 1800px;
|
||||
--sidebar-width: 280px;
|
||||
--header-height: 72px;
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 10px;
|
||||
--radius-lg: 16px;
|
||||
--radius-xl: 24px;
|
||||
--transition-fast: 0.15s ease;
|
||||
--transition-normal: 0.3s ease;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: 16px;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||
background:
|
||||
linear-gradient(180deg, #0a0a12 0px, #0a0a12 72px, transparent 72px, transparent 100%),
|
||||
linear-gradient(180deg, rgba(10, 10, 18, 0.5) 0%, rgba(10, 10, 18, 0.3) 30%, rgba(10, 10, 18, 0.4) 70%, rgba(10, 10, 18, 0.75) 100%),
|
||||
url('/background.jpg');
|
||||
background-size: 100% 100%, 100% 100%, cover;
|
||||
background-position: top, top, center top;
|
||||
background-repeat: no-repeat;
|
||||
background-attachment: fixed;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
min-height: 100vh;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.page-bg {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ===== LAYOUT CONTAINERS ===== */
|
||||
|
||||
.container {
|
||||
width: 100%;
|
||||
max-width: var(--container-max);
|
||||
margin: 0 auto;
|
||||
padding: 0 48px;
|
||||
}
|
||||
|
||||
.container-wide {
|
||||
width: 100%;
|
||||
max-width: var(--container-wide);
|
||||
margin: 0 auto;
|
||||
padding: 0 48px;
|
||||
}
|
||||
|
||||
.container-narrow {
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
/* ===== HEADER / NAVIGATION ===== */
|
||||
|
||||
.site-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1000;
|
||||
height: var(--header-height);
|
||||
background: #0a0a12;
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.site-header .container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 800;
|
||||
background: linear-gradient(135deg, var(--accent-gold), var(--accent-purple-light));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
letter-spacing: -0.5px;
|
||||
text-shadow: 0 0 30px rgba(212, 168, 67, 0.3);
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
.nav-links a {
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
font-size: 0.95rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
transition: color var(--transition-fast);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.nav-links a:hover {
|
||||
color: var(--accent-gold);
|
||||
}
|
||||
|
||||
.nav-links a::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -4px;
|
||||
left: 0;
|
||||
width: 0;
|
||||
height: 2px;
|
||||
background: var(--accent-gold);
|
||||
transition: width var(--transition-normal);
|
||||
box-shadow: 0 0 8px rgba(212, 168, 67, 0.5);
|
||||
}
|
||||
|
||||
.nav-links a:hover::after {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* ===== BUTTONS ===== */
|
||||
|
||||
button,
|
||||
.btn,
|
||||
a.btn-primary,
|
||||
a.btn-secondary,
|
||||
a.btn-danger,
|
||||
a.btn-ghost {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 12px 28px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
border: 2px solid transparent;
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-normal);
|
||||
white-space: nowrap;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, var(--accent-gold-dim), var(--accent-gold));
|
||||
border-color: var(--accent-gold);
|
||||
color: #0a0a12;
|
||||
box-shadow: 0 0 15px rgba(212, 168, 67, 0.15);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-gold);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: rgba(107, 63, 160, 0.15);
|
||||
border-color: var(--border-light);
|
||||
color: var(--text-primary);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
border-color: var(--accent-purple-light);
|
||||
color: var(--accent-purple-light);
|
||||
background: rgba(107, 63, 160, 0.25);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: transparent;
|
||||
border-color: var(--error);
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: var(--error);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
color: var(--text-secondary);
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
.btn-ghost:hover {
|
||||
color: var(--accent-gold);
|
||||
background: rgba(212, 168, 67, 0.08);
|
||||
}
|
||||
|
||||
button:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* ===== FORM ELEMENTS ===== */
|
||||
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
background: rgba(15, 12, 25, 0.8);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-primary);
|
||||
padding: 14px 18px;
|
||||
border-radius: var(--radius-md);
|
||||
width: 100%;
|
||||
font-family: inherit;
|
||||
font-size: 1rem;
|
||||
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
input:focus,
|
||||
textarea:focus,
|
||||
select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-gold);
|
||||
box-shadow: 0 0 0 3px rgba(212, 168, 67, 0.1);
|
||||
}
|
||||
|
||||
input::placeholder,
|
||||
textarea::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 500;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
/* ===== CARDS ===== */
|
||||
|
||||
.card {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 32px;
|
||||
backdrop-filter: blur(16px);
|
||||
transition: border-color var(--transition-normal), box-shadow var(--transition-normal);
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
border-color: var(--border-light);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 24px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ===== GRID LAYOUTS ===== */
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.grid-2 {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.grid-3 {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
.grid-4 {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
|
||||
.grid-auto {
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
}
|
||||
|
||||
/* ===== HERO SECTION ===== */
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
min-height: 75vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: 80px 48px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hero::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(ellipse at 30% 40%, rgba(107, 63, 160, 0.2) 0%, transparent 50%),
|
||||
radial-gradient(ellipse at 70% 60%, rgba(212, 168, 67, 0.12) 0%, transparent 50%),
|
||||
linear-gradient(180deg, transparent 0%, rgba(10, 10, 18, 0.4) 100%);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.hero-content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
max-width: 900px;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
font-size: clamp(2.5rem, 5vw, 4.5rem);
|
||||
font-weight: 900;
|
||||
line-height: 1.1;
|
||||
margin-bottom: 24px;
|
||||
background: linear-gradient(135deg, var(--text-primary) 0%, var(--accent-gold) 50%, var(--accent-purple-light) 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
filter: drop-shadow(0 0 20px rgba(212, 168, 67, 0.2));
|
||||
}
|
||||
|
||||
.hero p {
|
||||
font-size: clamp(1.1rem, 2vw, 1.35rem);
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 40px;
|
||||
max-width: 600px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.hero-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
/* ===== SECTIONS ===== */
|
||||
|
||||
.section {
|
||||
padding: 80px 0;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
text-align: center;
|
||||
margin-bottom: 56px;
|
||||
}
|
||||
|
||||
.section-header h2 {
|
||||
font-size: clamp(1.75rem, 3vw, 2.5rem);
|
||||
font-weight: 800;
|
||||
margin-bottom: 12px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.section-header p {
|
||||
font-size: 1.1rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* ===== NEWS SECTION ===== */
|
||||
|
||||
.news-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(380px, 1fr));
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
.news-card {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 32px;
|
||||
backdrop-filter: blur(16px);
|
||||
transition: all var(--transition-normal);
|
||||
}
|
||||
|
||||
.news-card:hover {
|
||||
border-color: var(--accent-gold-dim);
|
||||
transform: translateY(-4px);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.news-date {
|
||||
font-size: 0.85rem;
|
||||
color: var(--accent-gold);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.news-card h3 {
|
||||
font-size: 1.35rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 12px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.news-card p {
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
/* ===== DOWNLOAD SECTION ===== */
|
||||
|
||||
.download-section {
|
||||
background: var(--bg-secondary);
|
||||
border-top: 1px solid var(--border);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 64px 0;
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.download-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 48px;
|
||||
}
|
||||
|
||||
.download-info h2 {
|
||||
font-size: 2rem;
|
||||
font-weight: 800;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.download-info p {
|
||||
color: var(--text-secondary);
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.download-actions {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ===== CHARACTER CARDS ===== */
|
||||
|
||||
.characters-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
|
||||
gap: 28px;
|
||||
}
|
||||
|
||||
.character-card {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 28px;
|
||||
backdrop-filter: blur(16px);
|
||||
transition: all var(--transition-normal);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.character-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
background: linear-gradient(90deg, var(--accent-gold), var(--accent-purple-light));
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition-normal);
|
||||
box-shadow: 0 0 15px rgba(212, 168, 67, 0.3);
|
||||
}
|
||||
|
||||
.character-card:hover {
|
||||
border-color: var(--border-light);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.character-card:hover::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.character-name {
|
||||
font-size: 1.35rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 16px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.character-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.character-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* ===== DASHBOARD LAYOUT ===== */
|
||||
|
||||
.dashboard {
|
||||
min-height: calc(100vh - var(--header-height));
|
||||
}
|
||||
|
||||
.dashboard-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 32px 48px;
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.dashboard-header h1 {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.dashboard-content {
|
||||
padding: 40px 48px;
|
||||
}
|
||||
|
||||
.dashboard-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 24px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 28px;
|
||||
text-align: center;
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.stat-card .stat-number {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 900;
|
||||
background: linear-gradient(135deg, var(--accent-gold), var(--accent-purple-light));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.stat-card .stat-label {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* ===== AUTH PAGES ===== */
|
||||
|
||||
.auth-page {
|
||||
min-height: calc(100vh - var(--header-height));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 48px;
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: 48px;
|
||||
width: 100%;
|
||||
max-width: 520px;
|
||||
box-shadow: var(--shadow-card);
|
||||
backdrop-filter: blur(20px);
|
||||
}
|
||||
|
||||
.auth-card h1 {
|
||||
font-size: 2rem;
|
||||
font-weight: 800;
|
||||
margin-bottom: 8px;
|
||||
text-align: center;
|
||||
background: linear-gradient(135deg, var(--text-primary), var(--accent-gold));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.auth-card .subtitle {
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
margin-bottom: 36px;
|
||||
}
|
||||
|
||||
.auth-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.auth-form .form-group {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.auth-form button {
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.auth-footer {
|
||||
text-align: center;
|
||||
margin-top: 28px;
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid var(--border);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* ===== MODAL ===== */
|
||||
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
backdrop-filter: blur(12px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 48px;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: 40px;
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: var(--shadow-card);
|
||||
backdrop-filter: blur(20px);
|
||||
}
|
||||
|
||||
.modal-content h2 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ===== EMPTY STATE ===== */
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 80px 40px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
color: var(--text-secondary);
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
/* ===== FOOTER ===== */
|
||||
|
||||
.site-footer {
|
||||
background: var(--bg-secondary);
|
||||
border-top: 1px solid var(--border);
|
||||
padding: 48px 0;
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.site-footer .container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.footer-links {
|
||||
display: flex;
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
.footer-links a {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.footer-links a:hover {
|
||||
color: var(--accent-gold);
|
||||
}
|
||||
|
||||
.footer-copyright {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* ===== BADGES ===== */
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.badge-gold {
|
||||
background: rgba(212, 168, 67, 0.15);
|
||||
color: var(--accent-gold);
|
||||
border: 1px solid rgba(212, 168, 67, 0.2);
|
||||
}
|
||||
|
||||
.badge-purple {
|
||||
background: rgba(107, 63, 160, 0.15);
|
||||
color: var(--accent-purple-light);
|
||||
border: 1px solid rgba(107, 63, 160, 0.2);
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
background: rgba(16, 185, 129, 0.15);
|
||||
color: var(--success);
|
||||
border: 1px solid rgba(16, 185, 129, 0.2);
|
||||
}
|
||||
|
||||
/* ===== LOADING ===== */
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 80px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.loading::before {
|
||||
content: '';
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid var(--border);
|
||||
border-top-color: var(--accent-gold);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
margin-right: 16px;
|
||||
box-shadow: 0 0 10px rgba(212, 168, 67, 0.3);
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* ===== RESPONSIVE ===== */
|
||||
|
||||
@media (max-width: 1400px) {
|
||||
.container,
|
||||
.container-wide {
|
||||
padding: 0 36px;
|
||||
}
|
||||
|
||||
.dashboard-header,
|
||||
.dashboard-content {
|
||||
padding-left: 36px;
|
||||
padding-right: 36px;
|
||||
}
|
||||
|
||||
.dashboard-stats {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.container,
|
||||
.container-wide {
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
.hero {
|
||||
padding: 60px 24px;
|
||||
min-height: 50vh;
|
||||
}
|
||||
|
||||
.grid-3,
|
||||
.grid-4 {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.download-content {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
gap: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.grid-2,
|
||||
.grid-3,
|
||||
.grid-4 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.characters-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.news-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.hero-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.dashboard-stats {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.dashboard-header {
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.site-footer .container {
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.modal-overlay {
|
||||
padding: 24px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App.tsx';
|
||||
import './index.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useState } from 'react';
|
||||
import api from '../utils/api';
|
||||
import { useCharacters } from '../hooks/useCharacters';
|
||||
|
||||
function AddCharacterPage() {
|
||||
const [name, setName] = useState('');
|
||||
const [stats, setStats] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { fetchCharacters } = useCharacters();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
try {
|
||||
await api.post('/users/me/characters', {
|
||||
name,
|
||||
statsJson: JSON.parse(stats) || {}
|
||||
});
|
||||
setName('');
|
||||
setStats('');
|
||||
fetchCharacters();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>Add New Character</h2>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
placeholder="Character Name"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<textarea
|
||||
name="stats"
|
||||
placeholder='{"level": 1, "class": "warrior"}'
|
||||
value={stats}
|
||||
onChange={e => setStats(e.target.value)}
|
||||
/>
|
||||
<button type="submit" disabled={loading}>
|
||||
{loading ? 'Creating...' : 'Create Character'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AddCharacterPage;
|
||||
@@ -0,0 +1,235 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import api from '../utils/api';
|
||||
import { useCharacters } from '../hooks/useCharacters';
|
||||
import CharacterCard from '../components/CharacterCard';
|
||||
|
||||
interface GameCharacter {
|
||||
name: string;
|
||||
level: number;
|
||||
str: number;
|
||||
dex: number;
|
||||
con: number;
|
||||
int: number;
|
||||
wit: number;
|
||||
men: number;
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
hp: number;
|
||||
mp: number;
|
||||
maxHp: number;
|
||||
maxMp: number;
|
||||
exp: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
function DashboardPage() {
|
||||
const navigate = useNavigate();
|
||||
const { characters, loading, fetchCharacters } = useCharacters();
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
const [createError, setCreateError] = useState('');
|
||||
const [userId, setUserId] = useState('');
|
||||
|
||||
const handleDelete = async (charName: string) => {
|
||||
if (!confirm(`Are you sure you want to delete character "${charName}"?`)) return;
|
||||
try {
|
||||
await api.delete(`/users/me/characters/${charName}`, { data: { userId } });
|
||||
fetchCharacters();
|
||||
} catch {
|
||||
alert('Failed to delete character');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreate = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setCreateError('');
|
||||
const formData = new FormData(e.currentTarget);
|
||||
const name = formData.get('name') as string;
|
||||
|
||||
if (!name || name.length < 3) {
|
||||
setCreateError('Character name must be at least 3 characters');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!userId) {
|
||||
setCreateError('User account is required');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await api.post('/users/me/characters', { name, userId });
|
||||
fetchCharacters();
|
||||
setShowCreateForm(false);
|
||||
} catch {
|
||||
setCreateError('Failed to create character. Name may already be taken or user not found.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="dashboard">
|
||||
<header className="dashboard-header">
|
||||
<div>
|
||||
<h1>My Characters</h1>
|
||||
<p style={{ color: 'var(--text-secondary)', marginTop: '4px', fontSize: '0.95rem' }}>
|
||||
Manage your Lineage II characters on the game server
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '12px' }}>
|
||||
<button className="btn-primary" onClick={() => setShowCreateForm(true)}>
|
||||
+ New Character
|
||||
</button>
|
||||
<button className="btn-secondary" onClick={() => navigate('/')}>
|
||||
Back to Home
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="dashboard-content">
|
||||
{/* User Account Input */}
|
||||
<div className="card" style={{ marginBottom: '24px' }}>
|
||||
<div className="form-group" style={{ marginBottom: 0 }}>
|
||||
<label htmlFor="userId">Game Server Account</label>
|
||||
<div style={{ display: 'flex', gap: '12px' }}>
|
||||
<input
|
||||
id="userId"
|
||||
type="text"
|
||||
value={userId}
|
||||
onChange={e => setUserId(e.target.value)}
|
||||
placeholder="Enter your game server account name"
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<button className="btn-secondary" onClick={fetchCharacters}>
|
||||
Load Characters
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Row */}
|
||||
<div className="dashboard-stats">
|
||||
<div className="stat-card">
|
||||
<div className="stat-number">{characters.length}</div>
|
||||
<div className="stat-label">Total Characters</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-number">
|
||||
{(characters as GameCharacter[]).filter(c => c.level > 1).length}
|
||||
</div>
|
||||
<div className="stat-label">Active Characters</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-number">3</div>
|
||||
<div className="stat-label">Max Slots</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-number">
|
||||
{3 - characters.length}
|
||||
</div>
|
||||
<div className="stat-label">Slots Available</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Characters Section */}
|
||||
<div className="card" style={{ marginBottom: '32px' }}>
|
||||
<div className="card-header">
|
||||
<h2 className="card-title">Character Roster</h2>
|
||||
<button className="btn-primary" onClick={() => setShowCreateForm(true)}>
|
||||
+ Add Character
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="loading">Loading characters...</div>
|
||||
) : characters.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<p style={{ fontSize: '1.25rem', marginBottom: '8px', color: 'var(--text-primary)', fontWeight: 600 }}>
|
||||
No characters yet
|
||||
</p>
|
||||
<p style={{ marginBottom: '24px' }}>
|
||||
Create your first character to begin your adventure in the world of Lineage II
|
||||
</p>
|
||||
<button className="btn-primary" onClick={() => setShowCreateForm(true)}>
|
||||
Create Your First Character
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="characters-grid">
|
||||
{(characters as GameCharacter[]).map((character) => (
|
||||
<CharacterCard
|
||||
key={character.name}
|
||||
id={0}
|
||||
name={character.name}
|
||||
stats={{
|
||||
level: character.level,
|
||||
str: character.str,
|
||||
dex: character.dex,
|
||||
con: character.con,
|
||||
int: character.int,
|
||||
wit: character.wit,
|
||||
men: character.men,
|
||||
}}
|
||||
onDelete={() => handleDelete(character.name)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Create Character Modal */}
|
||||
{showCreateForm && (
|
||||
<div className="modal-overlay" onClick={() => setShowCreateForm(false)}>
|
||||
<div className="modal-content" onClick={e => e.stopPropagation()}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '28px' }}>
|
||||
<h2>Create New Character</h2>
|
||||
<button className="btn-ghost" onClick={() => setShowCreateForm(false)} style={{ fontSize: '1.5rem', padding: '4px 8px' }}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{createError && (
|
||||
<div style={{
|
||||
padding: '12px 16px',
|
||||
background: 'rgba(239, 68, 68, 0.1)',
|
||||
border: '1px solid rgba(239, 68, 68, 0.3)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
color: 'var(--error)',
|
||||
marginBottom: '20px',
|
||||
fontSize: '0.9rem',
|
||||
}}>
|
||||
{createError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleCreate}>
|
||||
<div className="form-group">
|
||||
<label htmlFor="char-name">Character Name</label>
|
||||
<input
|
||||
id="char-name"
|
||||
name="name"
|
||||
type="text"
|
||||
placeholder="Enter character name"
|
||||
required
|
||||
minLength={3}
|
||||
maxLength={16}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '12px', marginTop: '28px' }}>
|
||||
<button className="btn-primary" type="submit" style={{ flex: 1 }}>
|
||||
Create Character
|
||||
</button>
|
||||
<button className="btn-secondary" type="button" onClick={() => setShowCreateForm(false)}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DashboardPage;
|
||||
@@ -0,0 +1,154 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
function LandingPage() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const newsItems = [
|
||||
{
|
||||
title: 'Server Update v2.0 - Interlude',
|
||||
date: 'April 2026',
|
||||
tag: 'Update',
|
||||
content: 'Major server update with Interlude chronicle support. New zones, raids, and balance changes across all classes.',
|
||||
},
|
||||
{
|
||||
title: 'Weekend XP Event - Double Rates',
|
||||
date: 'March 2026',
|
||||
tag: 'Event',
|
||||
content: 'This weekend enjoy double experience and drop rates. Gather your party and level up faster than ever.',
|
||||
},
|
||||
{
|
||||
title: 'Grand Opening - Server Launch',
|
||||
date: 'January 2026',
|
||||
tag: 'Announcement',
|
||||
content: 'Welcome to our Lineage 2 server! We are proud to offer a stable, community-driven experience with custom features.',
|
||||
},
|
||||
{
|
||||
title: 'Class Balance Patch Notes',
|
||||
date: 'January 2026',
|
||||
tag: 'Patch',
|
||||
content: 'Comprehensive class balance adjustments. Mages received damage buffs while tanks got improved survivability.',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Header */}
|
||||
<header className="site-header">
|
||||
<div className="container">
|
||||
<div className="logo">LA2 Eternal</div>
|
||||
<nav className="nav-links">
|
||||
<a href="/">Home</a>
|
||||
<a href="/login">Login</a>
|
||||
<button className="btn-primary" onClick={() => navigate('/register')}>
|
||||
Register
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Hero */}
|
||||
<section className="hero">
|
||||
<div className="hero-content">
|
||||
<h1>Enter the World of Lineage II</h1>
|
||||
<p>
|
||||
Join our community-driven server with custom features, balanced gameplay,
|
||||
and an active player base. Your adventure begins here.
|
||||
</p>
|
||||
<div className="hero-actions">
|
||||
<button className="btn-primary" onClick={() => navigate('/register')}>
|
||||
Start Playing
|
||||
</button>
|
||||
<button className="btn-secondary" onClick={() => navigate('/login')}>
|
||||
Member Login
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* News */}
|
||||
<section className="section">
|
||||
<div className="container-wide">
|
||||
<div className="section-header">
|
||||
<h2>Latest News</h2>
|
||||
<p>Stay updated with server events, patches, and announcements</p>
|
||||
</div>
|
||||
<div className="news-grid">
|
||||
{newsItems.map((news, i) => (
|
||||
<article className="news-card" key={i}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '16px' }}>
|
||||
<span className={`badge badge-${news.tag === 'Update' ? 'gold' : news.tag === 'Event' ? 'purple' : 'success'}`}>
|
||||
{news.tag}
|
||||
</span>
|
||||
<span className="news-date">{news.date}</span>
|
||||
</div>
|
||||
<h3>{news.title}</h3>
|
||||
<p>{news.content}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Download */}
|
||||
<section className="download-section">
|
||||
<div className="container-wide">
|
||||
<div className="download-content">
|
||||
<div className="download-info">
|
||||
<h2>Download Game Client</h2>
|
||||
<p>Get the latest game client and start your adventure today</p>
|
||||
</div>
|
||||
<div className="download-actions">
|
||||
<a href="/downloads/la2eternal-client.zip" className="btn-primary" download>
|
||||
Download Full Client (3.3 GB)
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Server Info */}
|
||||
<section className="section">
|
||||
<div className="container-wide">
|
||||
<div className="section-header">
|
||||
<h2>Server Information</h2>
|
||||
<p>Everything you need to know about our server</p>
|
||||
</div>
|
||||
<div className="grid grid-4">
|
||||
<div className="stat-card">
|
||||
<div className="stat-number">x10</div>
|
||||
<div className="stat-label">Experience Rate</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-number">x3</div>
|
||||
<div className="stat-label">Drop Rate</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-number">x1</div>
|
||||
<div className="stat-label">Adena Rate</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-number">Interlude</div>
|
||||
<div className="stat-label">Chronicle</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="site-footer">
|
||||
<div className="container">
|
||||
<div className="footer-links">
|
||||
<a href="/">Home</a>
|
||||
<a href="/login">Login</a>
|
||||
<a href="/register">Register</a>
|
||||
</div>
|
||||
<div className="footer-copyright">
|
||||
2026 LA2 Eternal. All rights reserved.
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LandingPage;
|
||||
@@ -0,0 +1,97 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
|
||||
function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const { login } = useAuth();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
try {
|
||||
await login(email, password);
|
||||
navigate('/dashboard');
|
||||
} catch {
|
||||
setError('Invalid email or password. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<header className="site-header">
|
||||
<div className="container">
|
||||
<div className="logo" onClick={() => navigate('/')} style={{ cursor: 'pointer' }}>
|
||||
LA2 Eternal
|
||||
</div>
|
||||
<nav className="nav-links">
|
||||
<a href="/">Home</a>
|
||||
<a href="/register">Register</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="auth-page">
|
||||
<div className="auth-card">
|
||||
<h1>Welcome Back</h1>
|
||||
<p className="subtitle">Sign in to access your account and characters</p>
|
||||
|
||||
{error && (
|
||||
<div style={{
|
||||
padding: '12px 16px',
|
||||
background: 'rgba(239, 68, 68, 0.1)',
|
||||
border: '1px solid rgba(239, 68, 68, 0.3)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
color: 'var(--error)',
|
||||
marginBottom: '20px',
|
||||
fontSize: '0.9rem',
|
||||
}}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleLogin} className="auth-form">
|
||||
<div className="form-group">
|
||||
<label htmlFor="email">Email Address</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
placeholder="your@email.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="password">Password</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
placeholder="Enter your password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button className="btn-primary" type="submit" disabled={loading}>
|
||||
{loading ? 'Signing in...' : 'Sign In'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="auth-footer">
|
||||
Don't have an account? <a href="/register">Create one</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LoginPage;
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import api from '../utils/api';
|
||||
|
||||
function RegisterPage() {
|
||||
const navigate = useNavigate();
|
||||
const [username, setUsername] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError('Passwords do not match');
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
setError('Password must be at least 6 characters');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
await api.post('/auth/register', { username, email, password });
|
||||
navigate('/login');
|
||||
} catch {
|
||||
setError('Registration failed. Username or email may already be taken.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<header className="site-header">
|
||||
<div className="container">
|
||||
<div className="logo" onClick={() => navigate('/')} style={{ cursor: 'pointer' }}>
|
||||
LA2 Eternal
|
||||
</div>
|
||||
<nav className="nav-links">
|
||||
<a href="/">Home</a>
|
||||
<a href="/login">Login</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="auth-page">
|
||||
<div className="auth-card">
|
||||
<h1>Create Account</h1>
|
||||
<p className="subtitle">Join our community and start your adventure</p>
|
||||
|
||||
{error && (
|
||||
<div style={{
|
||||
padding: '12px 16px',
|
||||
background: 'rgba(239, 68, 68, 0.1)',
|
||||
border: '1px solid rgba(239, 68, 68, 0.3)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
color: 'var(--error)',
|
||||
marginBottom: '20px',
|
||||
fontSize: '0.9rem',
|
||||
}}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="auth-form">
|
||||
<div className="form-group">
|
||||
<label htmlFor="username">Username</label>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
placeholder="Choose a username"
|
||||
required
|
||||
minLength={3}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="email">Email Address</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
placeholder="your@email.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="password">Password</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
placeholder="Minimum 6 characters"
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="confirmPassword">Confirm Password</label>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={e => setConfirmPassword(e.target.value)}
|
||||
placeholder="Re-enter your password"
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
<button className="btn-primary" type="submit" disabled={loading}>
|
||||
{loading ? 'Creating Account...' : 'Create Account'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="auth-footer">
|
||||
Already have an account? <a href="/login">Sign in</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default RegisterPage;
|
||||
@@ -0,0 +1,17 @@
|
||||
export interface Character {
|
||||
id: number;
|
||||
name: string;
|
||||
statsJson: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
createdAt: string;
|
||||
isAdmin: boolean;
|
||||
maxCharacters?: number;
|
||||
characters?: Character[];
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
export interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
createdAt: string;
|
||||
isAdmin: boolean;
|
||||
maxCharacters?: number;
|
||||
characters?: Character[];
|
||||
}
|
||||
|
||||
export interface Character {
|
||||
id: number;
|
||||
name: string;
|
||||
statsJson: Record<string, any>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CharacterResponse extends Character {
|
||||
user?: {
|
||||
username: string;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_URL || 'api',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
// Add auth token to requests
|
||||
api.interceptors.request.use(config => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
// Handle errors
|
||||
api.interceptors.response.use(
|
||||
response => response,
|
||||
error => {
|
||||
console.error(error.response?.data?.error || error.message);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export default api;
|
||||
@@ -0,0 +1,27 @@
|
||||
import axiosLib, { AxiosInstance } from 'axios';
|
||||
|
||||
export const api: AxiosInstance = axiosLib.create({
|
||||
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3001',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
api.interceptors.request.use(config => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
const message = error.response?.data?.error || 'An error occurred';
|
||||
console.error(message);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export default api;
|
||||
@@ -0,0 +1,9 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_URL: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
base: './',
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src')
|
||||
}
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
export const config = {
|
||||
api: import.meta.env.VITE_API_URL || 'http://localhost:3001'
|
||||
};
|
||||
@@ -0,0 +1,155 @@
|
||||
{
|
||||
"name": "@types/node",
|
||||
"version": "25.5.2",
|
||||
"description": "TypeScript definitions for node",
|
||||
"homepage": "https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/node",
|
||||
"license": "MIT",
|
||||
"contributors": [
|
||||
{
|
||||
"name": "Microsoft TypeScript",
|
||||
"githubUsername": "Microsoft",
|
||||
"url": "https://github.com/Microsoft"
|
||||
},
|
||||
{
|
||||
"name": "Alberto Schiabel",
|
||||
"githubUsername": "jkomyno",
|
||||
"url": "https://github.com/jkomyno"
|
||||
},
|
||||
{
|
||||
"name": "Andrew Makarov",
|
||||
"githubUsername": "r3nya",
|
||||
"url": "https://github.com/r3nya"
|
||||
},
|
||||
{
|
||||
"name": "Benjamin Toueg",
|
||||
"githubUsername": "btoueg",
|
||||
"url": "https://github.com/btoueg"
|
||||
},
|
||||
{
|
||||
"name": "David Junger",
|
||||
"githubUsername": "touffy",
|
||||
"url": "https://github.com/touffy"
|
||||
},
|
||||
{
|
||||
"name": "Mohsen Azimi",
|
||||
"githubUsername": "mohsen1",
|
||||
"url": "https://github.com/mohsen1"
|
||||
},
|
||||
{
|
||||
"name": "Nikita Galkin",
|
||||
"githubUsername": "galkin",
|
||||
"url": "https://github.com/galkin"
|
||||
},
|
||||
{
|
||||
"name": "Sebastian Silbermann",
|
||||
"githubUsername": "eps1lon",
|
||||
"url": "https://github.com/eps1lon"
|
||||
},
|
||||
{
|
||||
"name": "Wilco Bakker",
|
||||
"githubUsername": "WilcoBakker",
|
||||
"url": "https://github.com/WilcoBakker"
|
||||
},
|
||||
{
|
||||
"name": "Marcin Kopacz",
|
||||
"githubUsername": "chyzwar",
|
||||
"url": "https://github.com/chyzwar"
|
||||
},
|
||||
{
|
||||
"name": "Trivikram Kamat",
|
||||
"githubUsername": "trivikr",
|
||||
"url": "https://github.com/trivikr"
|
||||
},
|
||||
{
|
||||
"name": "Junxiao Shi",
|
||||
"githubUsername": "yoursunny",
|
||||
"url": "https://github.com/yoursunny"
|
||||
},
|
||||
{
|
||||
"name": "Ilia Baryshnikov",
|
||||
"githubUsername": "qwelias",
|
||||
"url": "https://github.com/qwelias"
|
||||
},
|
||||
{
|
||||
"name": "ExE Boss",
|
||||
"githubUsername": "ExE-Boss",
|
||||
"url": "https://github.com/ExE-Boss"
|
||||
},
|
||||
{
|
||||
"name": "Piotr Błażejewicz",
|
||||
"githubUsername": "peterblazejewicz",
|
||||
"url": "https://github.com/peterblazejewicz"
|
||||
},
|
||||
{
|
||||
"name": "Anna Henningsen",
|
||||
"githubUsername": "addaleax",
|
||||
"url": "https://github.com/addaleax"
|
||||
},
|
||||
{
|
||||
"name": "Victor Perin",
|
||||
"githubUsername": "victorperin",
|
||||
"url": "https://github.com/victorperin"
|
||||
},
|
||||
{
|
||||
"name": "NodeJS Contributors",
|
||||
"githubUsername": "NodeJS",
|
||||
"url": "https://github.com/NodeJS"
|
||||
},
|
||||
{
|
||||
"name": "Linus Unnebäck",
|
||||
"githubUsername": "LinusU",
|
||||
"url": "https://github.com/LinusU"
|
||||
},
|
||||
{
|
||||
"name": "wafuwafu13",
|
||||
"githubUsername": "wafuwafu13",
|
||||
"url": "https://github.com/wafuwafu13"
|
||||
},
|
||||
{
|
||||
"name": "Matteo Collina",
|
||||
"githubUsername": "mcollina",
|
||||
"url": "https://github.com/mcollina"
|
||||
},
|
||||
{
|
||||
"name": "Dmitry Semigradsky",
|
||||
"githubUsername": "Semigradsky",
|
||||
"url": "https://github.com/Semigradsky"
|
||||
},
|
||||
{
|
||||
"name": "René",
|
||||
"githubUsername": "Renegade334",
|
||||
"url": "https://github.com/Renegade334"
|
||||
},
|
||||
{
|
||||
"name": "Yagiz Nizipli",
|
||||
"githubUsername": "anonrig",
|
||||
"url": "https://github.com/anonrig"
|
||||
}
|
||||
],
|
||||
"main": "",
|
||||
"types": "index.d.ts",
|
||||
"typesVersions": {
|
||||
"<=5.6": {
|
||||
"*": [
|
||||
"ts5.6/*"
|
||||
]
|
||||
},
|
||||
"<=5.7": {
|
||||
"*": [
|
||||
"ts5.7/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/DefinitelyTyped/DefinitelyTyped.git",
|
||||
"directory": "types/node"
|
||||
},
|
||||
"scripts": {},
|
||||
"dependencies": {
|
||||
"undici-types": "~7.18.0"
|
||||
},
|
||||
"peerDependencies": {},
|
||||
"typesPublisherContentHash": "ecfeeb69f68108817337300f59f20907babb8c0a870a588637f3d9c8b96e73f5",
|
||||
"typeScriptVersion": "5.3"
|
||||
}
|
||||