Compare commits

...

3 Commits

Author SHA1 Message Date
Frank 09d12d3d8c extra learn_categories added to spell maintenance 2026-02-27 11:59:16 +01:00
Bardioc26 6ac04b2ae1 Char skill edit not saved (#36)
* Skill edit was not saved
* show warning when skill name is to be edited in char_skills
2026-02-27 11:59:16 +01:00
Bardioc26 261a6294cb Desktop app dynamic config of API Port (#40)
* added dynamic configuration of Port in Desktop app
* added dynamic configuration of API_URL to docker deployments.

Now it works with editing only .env file.
2026-02-27 11:55:30 +01:00
34 changed files with 934 additions and 69 deletions
+174
View File
@@ -0,0 +1,174 @@
# Runtime Configuration Implementation Summary
## What Was Implemented
Enhanced the BaMoRT frontend to support runtime configuration for **both desktop (Wails) and web production** deployments, eliminating the need to rebuild when changing API URLs.
## Architecture
### Desktop (Wails)
- Uses Go bindings to read API URL from `.env` file at runtime
- Method: `window['go']['main']['App']['GetAPIBaseURL']()`
- Configuration: `desktop/.env``API_PORT` variable
- No rebuild needed when changing port
### Web Production (NEW)
Uses a **multi-strategy fallback system**:
1. **config.json** (Priority 1)
- Loads `/config.json` from web root
- Can be modified after deployment
- Example: `{"apiBaseURL": "https://api.yourdomain.com"}`
2. **Auto-detection** (Priority 2)
- Probes `/api/public/version` at same origin
- Works automatically with reverse proxy setups
- Detects if backend and frontend share same domain
3. **VITE_API_URL** (Priority 3)
- Environment variable at build time
- Used for development: `VITE_API_URL=http://localhost:8180`
4. **Same Origin Fallback** (Priority 4)
- Uses `window.location.origin`
- Assumes backend and frontend on same domain
## Files Modified
### Core Implementation
- `/data/dev/bamort/frontend/src/utils/config.js` - Enhanced with multi-strategy config loading
- `/data/dev/bamort/frontend/src/utils/api.js` - Uses dynamic baseURL (already done for desktop)
- `/data/dev/bamort/desktop/main.go` - GetAPIBaseURL() Go binding (already done for desktop)
### Documentation
- `/data/dev/bamort/frontend/RUNTIME_CONFIG.md` - Complete web configuration guide
- `/data/dev/bamort/desktop/RUNTIME_CONFIG.md` - Desktop configuration guide (existing)
### Configuration Files
- `/data/dev/bamort/frontend/public/config.json.example` - Template for deployment
- `/data/dev/bamort/frontend/.gitignore` - Excludes `public/config.json` from git
## How It Works
### For Web Development
```bash
cd frontend
VITE_API_URL=http://localhost:8180 npm run dev
```
### For Web Production Deployment
**Option A: Using config.json (Recommended)**
```bash
# 1. Build once
cd frontend && npm run build
# 2. Deploy dist/ to web server
# 3. Create config.json in web root
cat > /var/www/bamort/config.json <<EOF
{
"apiBaseURL": "https://api.production.com"
}
EOF
# 4. Change API URL anytime without rebuild!
```
**Option B: Reverse Proxy Setup**
Example nginx configuration:
```nginx
server {
listen 80;
server_name yourdomain.com;
# Frontend
location / {
root /var/www/bamort;
try_files $uri $uri/ /index.html;
}
# Backend API
location /api {
proxy_pass http://localhost:8180;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
```
Frontend auto-detects and uses same origin.
**Option C: Build with Embedded URL**
```bash
VITE_API_URL=https://api.production.com npm run build
```
(Not recommended - requires rebuild for URL changes)
### For Desktop (Wails)
```bash
# 1. Build once
cd desktop && wails build
# 2. Change port in .env (no rebuild!)
echo "API_PORT=9000" > desktop/.env
# 3. Run app - uses new port automatically
./desktop/build/bin/bamort
```
## Verification
Open browser console when loading the app. Look for one of these messages:
**Desktop:**
```
Desktop app using API URL from config: http://localhost:8185
```
**Web:**
```
Loaded API URL from config.json: https://api.yourdomain.com
```
or
```
Detected backend at same origin: https://yourdomain.com
```
or
```
Web app using VITE_API_URL: http://localhost:8180
```
or
```
Web app using same origin: https://yourdomain.com
```
## Benefits
**No rebuild needed** for API URL changes in production web deployments
**Same build artifact** can be deployed to dev/staging/production
**Infrastructure-friendly** - works with reverse proxies, Docker, static hosting
**Flexible** - multiple configuration strategies with sensible fallbacks
**Dev-friendly** - VITE_API_URL still works for development
**Desktop-friendly** - reads .env at runtime (already implemented)
## Testing Checklist
- [x] Frontend builds successfully (`npm run build`)
- [x] Desktop app reads runtime config from .env
- [ ] Web dev mode works with VITE_API_URL
- [ ] Web production with config.json works
- [ ] Web production with reverse proxy auto-detection works
- [ ] Web production with same-origin fallback works
## Migration Path
**For existing deployments:**
1. No changes needed! Current builds continue working
2. To enable runtime config: Just add `config.json` to your web root
3. Old builds with VITE_API_URL still work as fallback
**For new deployments:**
1. Build once: `npm run build`
2. Deploy `dist/` contents
3. Add `config.json` with your API URL
4. Done!
+11 -1
View File
@@ -147,7 +147,17 @@ func UpdateCharacter(c *gin.Context) {
return
}
c.JSON(http.StatusOK, character)
// Reload character to get updated data
var updatedCharacter models.Char
err = updatedCharacter.FirstID(id)
if err != nil {
respondWithError(c, http.StatusInternalServerError, "Failed to reload character")
return
}
// Return as FeChar with categorized skills
feChar := ToFeChar(&updatedCharacter)
c.JSON(http.StatusOK, feChar)
}
func DeleteCharacter(c *gin.Context) {
id := c.Param("id")
+1
View File
@@ -47,6 +47,7 @@ func RegisterRoutes(r *gin.RouterGroup) {
maintGrp.PUT("/spells/:id", UpdateMDSpell)
maintGrp.PUT("/spells-enhanced/:id", UpdateEnhancedMDSpell) // New enhanced endpoint
maintGrp.POST("/spells", AddSpell)
maintGrp.POST("/spells-enhanced", CreateEnhancedMDSpell) // New enhanced endpoint
maintGrp.DELETE("/spells/:id", DeleteMDSpell)
maintGrp.PUT("/equipment/:id", UpdateMDEquipment)
+43 -3
View File
@@ -66,6 +66,15 @@ func UpdateSpellWithCategories(spellID uint, req SpellUpdateRequest) error {
})
}
// CreateSpellWithCategories creates a new spell
func CreateSpellWithCategories(req SpellUpdateRequest) (*models.Spell, error) {
spell := req.Spell
if err := database.DB.Create(&spell).Error; err != nil {
return nil, err
}
return &spell, nil
}
// ===== Handler Functions =====
// GetEnhancedMDSpells returns spells with enhanced information
@@ -87,11 +96,18 @@ func GetEnhancedMDSpells(c *gin.Context) {
respondWithError(c, http.StatusInternalServerError, "Failed to retrieve spell categories: "+err.Error())
return
}
// Get spell Learn_categories
learnCategories, err := spell.GetSpellLearnCategories()
if err != nil {
respondWithError(c, http.StatusInternalServerError, "Failed to retrieve spell learn categories: "+err.Error())
return
}
c.JSON(http.StatusOK, gin.H{
"spells": spells,
"sources": sources,
"categories": categories,
"spells": spells,
"sources": sources,
"categories": categories,
"learnCategories": learnCategories,
})
}
@@ -145,3 +161,27 @@ func UpdateEnhancedMDSpell(c *gin.Context) {
c.JSON(http.StatusOK, spell)
}
// CreateEnhancedMDSpell creates a new spell
func CreateEnhancedMDSpell(c *gin.Context) {
var req SpellUpdateRequest
if err := c.ShouldBindJSON(&req); err != nil {
respondWithError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
return
}
spell, err := CreateSpellWithCategories(req)
if err != nil {
respondWithError(c, http.StatusInternalServerError, "Failed to create spell: "+err.Error())
return
}
// Return created spell with enhanced information
spellWithCats, err := GetSpellWithCategories(spell.ID)
if err != nil {
respondWithError(c, http.StatusInternalServerError, "Failed to retrieve created spell")
return
}
c.JSON(http.StatusCreated, spellWithCats)
}
@@ -0,0 +1,153 @@
package gsmaster
import (
"bamort/database"
"bamort/models"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestUpdateSpellWithLearningCategory(t *testing.T) {
// Set test environment
setupTestEnvironment(t)
database.SetupTestDB(true)
defer database.ResetTestDB()
// Create a test spell
spell := models.Spell{
Name: "Testzauber",
Category: "Wunder",
LearningCategory: "",
Stufe: 1,
GameSystem: "midgard",
}
err := database.DB.Create(&spell).Error
require.NoError(t, err, "Failed to create test spell")
// Test 1: Update learning_category
spell.LearningCategory = "Wundertat"
updateReq := SpellUpdateRequest{Spell: spell}
err = UpdateSpellWithCategories(spell.ID, updateReq)
assert.NoError(t, err, "Failed to update spell with learning_category")
// Verify update
var updated models.Spell
err = database.DB.First(&updated, spell.ID).Error
require.NoError(t, err)
assert.Equal(t, "Wundertat", updated.LearningCategory, "Learning category not updated correctly")
assert.Equal(t, "Wunder", updated.Category, "Category should remain unchanged")
// Test 2: Update both categories
updated.Category = "Verändern"
updated.LearningCategory = "Zauberlied"
updateReq2 := SpellUpdateRequest{Spell: updated}
err = UpdateSpellWithCategories(updated.ID, updateReq2)
assert.NoError(t, err, "Failed to update both categories")
// Verify both were updated
var final models.Spell
err = database.DB.First(&final, updated.ID).Error
require.NoError(t, err)
assert.Equal(t, "Verändern", final.Category, "Category not updated")
assert.Equal(t, "Zauberlied", final.LearningCategory, "Learning category not updated")
}
func TestCreateSpellWithLearningCategory(t *testing.T) {
setupTestEnvironment(t)
database.SetupTestDB(true)
defer database.ResetTestDB()
// Test: Create spell with learning_category
spell := models.Spell{
Name: "Neuer Zauber",
Category: "Beherrschen",
LearningCategory: "Runenstab",
Stufe: 2,
GameSystem: "midgard",
AP: "3",
}
err := database.DB.Create(&spell).Error
require.NoError(t, err, "Failed to create spell with learning_category")
// Verify creation
var created models.Spell
err = database.DB.Where("name = ?", "Neuer Zauber").First(&created).Error
require.NoError(t, err)
assert.Equal(t, "Beherrschen", created.Category)
assert.Equal(t, "Runenstab", created.LearningCategory)
assert.Equal(t, 2, created.Stufe)
}
func TestLearningCategoryDefaultEmpty(t *testing.T) {
setupTestEnvironment(t)
database.SetupTestDB(true)
defer database.ResetTestDB()
// Test: Create spell without learning_category
spell := models.Spell{
Name: "Zauber ohne LearningCategory",
Category: "Normal",
Stufe: 1,
GameSystem: "midgard",
}
err := database.DB.Create(&spell).Error
require.NoError(t, err)
// Verify learning_category is empty (not nil)
var created models.Spell
err = database.DB.First(&created, spell.ID).Error
require.NoError(t, err)
assert.Equal(t, "", created.LearningCategory, "Learning category should be empty string by default")
assert.Equal(t, "Normal", created.Category)
}
func TestCreateEnhancedMDSpellEndpoint(t *testing.T) {
setupTestEnvironment(t)
database.SetupTestDB(true)
defer database.ResetTestDB()
// Test: Create spell with learning_category via endpoint
spell := models.Spell{
Name: "Test Endpoint Zauber",
Category: "Erkennen",
LearningCategory: "Erkenntnismagie",
Stufe: 3,
GameSystem: "midgard",
AP: "2",
}
req := SpellUpdateRequest{Spell: spell}
created, err := CreateSpellWithCategories(req)
require.NoError(t, err, "Failed to create spell")
assert.NotZero(t, created.ID, "Created spell should have an ID")
assert.Equal(t, "Erkennen", created.Category)
assert.Equal(t, "Erkenntnismagie", created.LearningCategory)
assert.Equal(t, 3, created.Stufe)
}
func TestGetSpellLearningCategories(t *testing.T) {
setupTestEnvironment(t)
database.SetupTestDB(true)
defer database.ResetTestDB()
var spell models.Spell
learningCategories, err := spell.GetSpellLearnCategories()
require.NoError(t, err, "Failed to get spell learning categories")
assert.Contains(t, learningCategories, "Spruch", "Learning categories should include 'Spruch'")
assert.Contains(t, learningCategories, "Runenstab", "Learning categories should include 'Runenstab'")
assert.Contains(t, learningCategories, "Zauberlied", "Learning categories should include 'Zauberlied'")
assert.Contains(t, learningCategories, "Wundertat", "Learning categories should include 'Wundertat'")
assert.Contains(t, learningCategories, "Dweomer", "Learning categories should include 'Dweomer'")
assert.Contains(t, learningCategories, "Thaumatherapie", "Learning categories should include 'Thaumatherapie'")
assert.Contains(t, learningCategories, "Zaubersalz", "Learning categories should include 'Zaubersalz'")
assert.Contains(t, learningCategories, "Rune", "Learning categories should include 'Rune'")
assert.Contains(t, learningCategories, "Siegel", "Learning categories should include 'Siegel'")
}
+16
View File
@@ -438,6 +438,22 @@ func (object *Spell) GetSpellCategories() ([]string, error) {
return categories, nil
}
func (object *Spell) GetSpellLearnCategories() ([]string, error) {
var categories []string
gs := GetGameSystem(object.GameSystemId, object.GameSystem)
result := database.DB.Model(&Spell{}).
Where("game_system = ? OR game_system_id = ?", gs.Name, gs.ID).
Distinct().
Pluck("learning_category", &categories)
if result.Error != nil {
return nil, result.Error
}
return categories, nil
}
func (object *Spell) ensureGameSystem() {
gs := GetGameSystem(object.GameSystemId, object.GameSystem)
object.GameSystemId = gs.ID
+1 -1
View File
@@ -32,7 +32,7 @@ func SetupGin(r *gin.Engine) {
}
corsConfig = cors.Config{
AllowOrigins: allowedOrigins,
AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE"},
AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
AllowHeaders: []string{"Origin", "Content-Type", "Authorization"},
ExposeHeaders: []string{"Content-Length"},
AllowCredentials: true,
+64
View File
@@ -0,0 +1,64 @@
# Desktop Runtime Configuration
The BaMoRT desktop app now uses **runtime configuration** for the API port, meaning you can change the port in the `.env` file without rebuilding the app.
## How It Works
### Backend (Go)
- **[desktop/main.go](desktop/main.go)**: The `App.GetAPIBaseURL()` method reads the configured port from `config.Cfg` and returns the full API URL
- This method is bound to the frontend via Wails, making it callable from JavaScript
### Frontend (Vue/JS)
- **[frontend/src/utils/config.js](frontend/src/utils/config.js)**: Detects if running in Wails and calls the Go backend to get the API URL at runtime
- **[frontend/src/utils/api.js](frontend/src/utils/api.js)**: Uses the dynamic config instead of a hardcoded VITE_API_URL
- The API URL is cached after first retrieval for performance
### Build Process
- **[frontend/package.json](frontend/package.json)**: The `build:desktop` script no longer hardcodes `VITE_API_URL`
- The frontend bundle is now port-agnostic
## Usage
### Change the API Port
1. Edit `desktop/.env`:
```env
API_PORT=8185 # Change to any port you want
```
2. Run the app - no rebuild needed!
```bash
./desktop/build/bin/bamort
```
The frontend will automatically connect to the configured port.
### Environment Detection
The config system automatically detects the environment:
- **Desktop (Wails)**: Calls `window.go.main.App.GetAPIBaseURL()` to get the URL from Go backend
- **Web (Development)**: Uses `import.meta.env.VITE_API_URL` from Vite
- **Web (Production)**: Defaults to `https://bamort-api.trokan.de`
## Fallback Behavior
If the desktop app can't reach the Go backend (shouldn't happen), it falls back to `http://localhost:8185`.
## Benefits
✅ Change API port without rebuilding
✅ Faster iteration during development
✅ Same binary works with different configurations
✅ Cleaner separation of config from code
## Technical Details
**Request Flow:**
1. Frontend makes API request
2. Axios interceptor checks if `baseURL` is set
3. If not set, calls `getAPIBaseURL()` from config.js
4. Config.js detects Wails environment via `window.go`
5. Calls Go backend's `GetAPIBaseURL()` method
6. Caches the result for subsequent requests
7. Request proceeds with correct baseURL
+13 -3
View File
@@ -1,6 +1,6 @@
// BaMoRT Desktop application entry point.
// Uses Wails v2 to wrap the existing Gin HTTP server in a native desktop window.
// The backend runs on localhost:8180 (SQLite); the WebView loads the bundled frontend.
// The backend runs on localhost:8185 (SQLite); the WebView loads the bundled frontend.
package main
import (
@@ -56,8 +56,15 @@ func (a *App) startup(ctx context.Context) {
startGinServer()
}
// GetAPIBaseURL returns the HTTP server address for the frontend to connect to.
// This allows the API port to be configured at runtime via .env without rebuilding.
func (a *App) GetAPIBaseURL() string {
// GetServerAddress returns ":port", so we need to add localhost
return "http://localhost" + config.Cfg.GetServerAddress()
}
// startGinServer initialises the database, runs migrations, and starts the
// Gin HTTP server on the configured port (default 8180) in a goroutine.
// Gin HTTP server on the configured port (default 8185) in a goroutine.
func startGinServer() {
cfg := config.Cfg
@@ -131,7 +138,7 @@ func main() {
_ = os.Setenv("ENVIRONMENT", "desktop")
}
if os.Getenv("API_PORT") == "" {
_ = os.Setenv("API_PORT", "8180")
_ = os.Setenv("API_PORT", "8185")
}
if os.Getenv("TEMPLATES_DIR") == "" {
_ = os.Setenv("TEMPLATES_DIR", "./templates")
@@ -158,6 +165,9 @@ func main() {
MinWidth: 1024,
MinHeight: 768,
OnStartup: app.startup,
Bind: []interface{}{
app,
},
AssetServer: &assetserver.Options{
Assets: frontendFS,
},
+5 -5
View File
@@ -1,9 +1,5 @@
# Environment variables for Bamort development environment
# API Configuration
# API_URL=http://localhost:8180
# Database Configuration (for development)
DATABASE_TYPE=mysql
DATABASE_URL=bamort:bG4)efozrc@tcp(mariadb-dev:3306)/bamort?charset=utf8mb4&parseTime=True&loc=Local
@@ -13,9 +9,13 @@ MARIADB_PASSWORD=bG4)efozrc
MARIADB_DATABASE=bamort
MARIADB_USER=bamort
# Frontend Configuration
# API Configuration
# API_URL is used by the frontend to connect to the backend
# For dev environment with Vite dev server, this goes to VITE_API_URL
API_URL=http://192.168.0.48:8180
API_PORT=8180
# Frontend Configuration
BASE_URL=http://localhost:5173
TEMPLATES_DIR=./templates
EXPORT_TEMP_DIR=./export_temp
+16 -3
View File
@@ -1,9 +1,16 @@
# Environment variables for Bamort production deployment
# Copy this file to .env and adjust the values as needed
# Copy this file to .env.dev or .env.prd and adjust the values as needed
#- API Configuration
# ===== FRONTEND API CONFIGURATION =====
# API_URL: The backend API endpoint that the frontend will use
# For production: Use your public API domain (https://api.yourdomain.com)
# For development: Use your local network IP (http://192.168.x.x:8180)
#
# IMPORTANT: After changing API_URL, just restart containers - NO REBUILD NEEDED!
# Production: docker-compose -f docker-compose.yml restart frontend
# Development: docker-compose -f docker-compose.dev.yml restart frontend-dev
API_URL=https://backend.domain.de
VITE_API_URL=https://backend.domain.de
API_PORT=8180
#- Database Configuration Backend
DATABASE_TYPE=mysql
@@ -20,3 +27,9 @@ BASE_URL=https://frontend.domain.de
TEMPLATES_DIR=./templates
EXPORT_TEMP_DIR=./export_temp
COMPOSE_PROJECT_NAME=bamort
# Mail Configuration (for development)
MAIL_HOST=mail.server.de
MAIL_PORT=465
MAIL_USERNAME=bamort@server.de
MAIL_PASSWORD=XXXZZZZZ.YYYXXX
+4 -4
View File
@@ -1,10 +1,10 @@
# Environment variables for Bamort production environment
# API Configuration
# API_URL is used by the frontend to generate config.json at container startup
# Change this value and restart containers (no rebuild needed!)
API_URL=https://bamort-api.trokan.de
VITE_API_URL=https://bamort-api.trokan.de
# Database Configuration Backend
API_PORT=8180
DATABASE_TYPE=mysql
#DATABASE_URL=bamort:bG4)efozrc@tcp(mariadb:3306)/bamort?charset=utf8mb4&parseTime=True&loc=Local
@@ -14,7 +14,7 @@ MARIADB_PASSWORD=bG4)efozrc
MARIADB_DATABASE=bamort
MARIADB_USER=bamort
API_PORT=8180
# Frontend Configuration
BASE_URL=https://bamort.trokan.de
TEMPLATES_DIR=./templates
EXPORT_TEMP_DIR=./export_temp
+12 -17
View File
@@ -1,42 +1,37 @@
# =========== 1) Build stage ===========
FROM node:22-alpine AS build
# Accept build arguments for Vite environment variables
ARG VITE_API_URL
ARG VITE_BASE_URL
ARG VITE_API_PORT
# Set them as environment variables for the build process
ENV VITE_API_URL=$VITE_API_URL
ENV VITE_BASE_URL=$VITE_BASE_URL
ENV VITE_API_PORT=$VITE_API_PORT
# No build args needed - using runtime configuration instead
WORKDIR /usr/src/app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm install
# Copy the rest of the frontend code
COPY . .
# Build the production bundle
# Build the production bundle WITHOUT baked-in API URL
# Runtime configuration will be generated from environment variables
RUN npm run build
# =========== 2) Serve stage ===========
FROM nginx:alpine
# Copy production build to Nginx html folder.
# Adjust /usr/src/app/build -> /usr/src/app/dist if youre using Angular/Vue
#COPY --from=build /usr/src/app/build /usr/share/nginx/html
# Copy production build to Nginx html folder
COPY --from=build /usr/src/app/dist /usr/share/nginx/html
# Copy custom nginx configuration for SPA routing
COPY --from=build /usr/src/app/nginx.conf /etc/nginx/conf.d/default.conf
# Copy entrypoint script that generates runtime config
COPY --from=build /usr/src/app/docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh
# Expose HTTP port
EXPOSE 80
# Run Nginx in foreground
CMD ["nginx", "-g", "daemon off;"]
# Use custom entrypoint that generates config.json from environment
ENTRYPOINT ["/docker-entrypoint.sh"]
+6 -2
View File
@@ -9,8 +9,12 @@ COPY package*.json ./
# Install dependencies
RUN npm install
# Copy entrypoint script that generates config.json
COPY docker-dev-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh
# Expose Vite dev server port
EXPOSE 5173
# Start development server with host binding for Docker
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
# Use entrypoint that generates config.json before starting Vite
ENTRYPOINT ["/docker-entrypoint.sh"]
+1 -7
View File
@@ -25,18 +25,12 @@ services:
build:
context: ../frontend
dockerfile: ../docker/Dockerfile.frontend
args:
VITE_API_URL: ${API_URL:-https://bamort-api.trokan.de}
VITE_BASE_URL: ${BASE_URL:-https://bamort.trokan.de}
VITE_API_PORT: ${API_PORT:-443}
container_name: bamort-frontend
ports:
- "8181:80"
environment:
- NODE_ENV=production
- VITE_API_URL=${API_URL:-https://bamort.trokan.de:8180}
- VITE_BASE_URL=${BASE_URL:-https://bamort.trokan.de}
- VITE_API_PORT=${API_PORT:-8180}
- API_URL=${API_URL:-https://bamort-api.trokan.de}
depends_on:
- backend
restart: unless-stopped
+35
View File
@@ -0,0 +1,35 @@
#!/bin/sh
set -e
# Generate config.json from VITE_API_URL for development
# This makes the dev environment behave the same as production
CONFIG_FILE="/app/public/config.json"
echo "🔧 Generating development config.json..."
# Use VITE_API_URL from environment
API_BASE_URL="${VITE_API_URL:-}"
if [ -z "$API_BASE_URL" ]; then
echo "⚠️ VITE_API_URL not set, creating minimal config"
cat > "$CONFIG_FILE" <<EOF
{
"_comment": "Development mode - VITE_API_URL will be used as fallback"
}
EOF
else
echo "✅ API URL configured: $API_BASE_URL"
cat > "$CONFIG_FILE" <<EOF
{
"apiBaseURL": "$API_BASE_URL"
}
EOF
fi
echo "📄 Created config.json:"
cat "$CONFIG_FILE"
# Start Vite dev server
echo "🚀 Starting Vite dev server..."
exec npm run dev -- --host 0.0.0.0
+36
View File
@@ -0,0 +1,36 @@
#!/bin/sh
set -e
# Generate config.json from environment variables at container startup
# This allows runtime configuration without rebuilding the container
CONFIG_FILE="/usr/share/nginx/html/config.json"
echo "🔧 Generating frontend runtime configuration..."
# Use API_URL from environment, or fallback to same origin
API_BASE_URL="${API_URL:-}"
if [ -z "$API_BASE_URL" ]; then
echo "⚠️ API_URL not set, frontend will auto-detect or use same origin"
# Create minimal config that triggers auto-detection
cat > "$CONFIG_FILE" <<EOF
{
"_comment": "API_URL not configured, using auto-detection"
}
EOF
else
echo "✅ API URL configured: $API_BASE_URL"
cat > "$CONFIG_FILE" <<EOF
{
"apiBaseURL": "$API_BASE_URL"
}
EOF
fi
echo "📄 Generated config.json:"
cat "$CONFIG_FILE"
# Start nginx
echo "🚀 Starting nginx..."
exec nginx -g "daemon off;"
+18 -1
View File
@@ -11,16 +11,33 @@ fi
# Gehe ins Docker-Verzeichnis
cd "$(dirname "$0")"
# Load development environment variables
if [ -f .env.dev ]; then
echo "📝 Loading configuration from .env.dev"
export $(grep -v '^#' .env.dev | xargs)
else
if [ -f .env ]; then
echo "📝 Loading configuration from .env"
export $(grep -v '^#' .env | xargs)
else
echo "⚠️ Warning: .env not found, using defaults"
fi
fi
# Get current git commit
export GIT_COMMIT=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")
echo "📝 Git Commit: $GIT_COMMIT"
echo "📦 Building and starting development containers..."
echo "🔧 Frontend will use API: ${API_URL:-http://localhost:8180}"
# Stoppe vorhandene Container
docker-compose -f docker-compose.dev.yml down
# Baue und starte die Container
docker-compose -f docker-compose.dev.yml up --build -d
docker-compose -f docker-compose.dev.yml --env-file .env.dev up --build -d
echo "✅ Development environment started."
echo "📱 Frontend: http://localhost:5173"
echo "🔌 Backend: http://localhost:8180"
echo "🗄️ phpMyAdmin: http://localhost:8081"
+24 -3
View File
@@ -11,14 +11,35 @@ fi
# Gehe ins Docker-Verzeichnis
cd "$(dirname "$0")"
# Load production environment variables
if [ -f .env.prd ]; then
echo "📝 Loading configuration from .env.prd"
export $(grep -v '^#' .env.prd | xargs)
else
if [ -f .env ]; then
echo "📝 Loading configuration from .env"
export $(grep -v '^#' .env | xargs)
else
echo "⚠️ Warning: .env not found, using defaults"
fi
fi
echo "📦 Building and starting production containers..."
echo "🔧 Frontend will use API: ${API_URL:-https://bamort-api.trokan.de}"
# Build before stopping existing containers
docker-compose -f docker-compose.yml build
docker-compose -f docker-compose.yml --env-file .env.prd build
# Stoppe vorhandene Container
docker-compose -f docker-compose.yml down
# Baue und starte die Container
docker-compose -f docker-compose.yml up --build -d
docker-compose -f docker-compose.yml --env-file .env.prd up -d
echo "✅ Production environment started."
echo "✅ Production environment started."
echo "📱 Frontend: http://localhost:8181"
echo "🔌 Backend: http://localhost:8182"
echo ""
echo "💡 To change API URL: Edit .env.prd and run:"
echo " docker-compose -f docker-compose.yml restart frontend"
echo " (No rebuild needed!)"
+7
View File
@@ -13,6 +13,13 @@ dist
dist-ssr
*.local
# Runtime configuration (use config.json.example as template)
public/config.json
# Docker entrypoint scripts (copied from ../docker/ during build)
docker-entrypoint.sh
docker-dev-entrypoint.sh
# Editor directories and files
.vscode/*
!.vscode/extensions.json
+1 -1
View File
@@ -7,7 +7,7 @@
"scripts": {
"dev": "vite",
"build": "vite build",
"build:desktop": "DESKTOP_BUILD=1 VITE_API_URL=http://localhost:8180 vite build",
"build:desktop": "DESKTOP_BUILD=1 vite build",
"preview": "vite preview"
},
"dependencies": {
+1 -1
View File
@@ -1 +1 @@
a98925179cb067ef9c9e5871faa6f8c7
705a38c2f133892600ec967917c4d556
+4
View File
@@ -0,0 +1,4 @@
{
"apiBaseURL": "http://localhost:8180",
"_comment": "This file can be modified at deployment without rebuilding. Copy to config.json and adjust apiBaseURL for your environment."
}
+7 -1
View File
@@ -32,7 +32,7 @@
<!-- Submenu Content -->
<!-- <div class="character-aspect"> -->
<component :is="currentView" :character="character" :isOwner="isOwner" @character-updated="refreshCharacter"/>
<component :is="currentView" :character="character" :isOwner="isOwner" @character-updated="refreshCharacter" @character-data-updated="updateCharacterData"/>
<!-- </div> -->
<!-- Submenu -->
@@ -156,6 +156,12 @@ export default {
alert('Fehler beim Aktualisieren der Charakterdaten: ' + (error.response?.data?.error || error.message));
}
},
updateCharacterData(updatedData) {
// Update character data directly without reloading from server
this.character = updatedData;
console.log('Character data updated directly from response');
},
},
};
</script>
+75 -5
View File
@@ -55,6 +55,15 @@
</div>
</div>
</div>
<!-- Warning message when editing skill name -->
<div v-if="showNameEditWarning" class="warning-message">
<span class="warning-icon"></span>
<span class="warning-text">
{{ $t('characters.datasheet.editnamewarning') }}
</span>
</div>
<table class="cd-table">
<thead>
<tr>
@@ -384,6 +393,29 @@
transform: translateX(0);
}
}
.warning-message {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
margin: 10px 0;
background-color: #fff3cd;
border: 1px solid #ffc107;
border-radius: 6px;
color: #856404;
animation: slideIn 0.3s ease;
}
.warning-icon {
font-size: 1.2rem;
flex-shrink: 0;
}
.warning-text {
font-size: 0.9rem;
line-height: 1.4;
}
</style>
<script>
@@ -431,6 +463,7 @@ export default {
editingSkillId: null,
editingField: null,
editValue: '',
showNameEditWarning: false,
isLoading: false
};
@@ -752,6 +785,11 @@ export default {
this.editingField = field;
this.editValue = skill[field] || '';
// Show warning when editing skill name
if (field === 'name') {
this.showNameEditWarning = true;
}
this.$nextTick(() => {
const input = this.$refs.editInput;
if (input) {
@@ -779,19 +817,50 @@ export default {
}
}
// Update local character object
skill[field] = newValue;
// Find and update the original skill in character.fertigkeiten or character.waffenfertigkeiten
let originalSkillFound = false;
// Check in fertigkeiten
if (this.character.fertigkeiten) {
const originalSkill = this.character.fertigkeiten.find(s => s.name === skill.name);
if (originalSkill) {
originalSkill[field] = newValue;
originalSkillFound = true;
}
}
// Check in waffenfertigkeiten if not found in fertigkeiten
if (!originalSkillFound && this.character.waffenfertigkeiten) {
const originalWeaponSkill = this.character.waffenfertigkeiten.find(s => s.name === skill.name);
if (originalWeaponSkill) {
originalWeaponSkill[field] = newValue;
originalSkillFound = true;
}
}
if (!originalSkillFound) {
console.warn('Original skill not found in character data:', skill.name);
alert('Warnung: Originalfertigkeit nicht gefunden.');
this.cancelEditSkill();
return;
}
try {
// Save to backend
await API.put(`/api/characters/${this.character.id}`, this.character);
const response = await API.put(`/api/characters/${this.character.id}`, this.character);
console.log('Skill updated successfully:', skill.name, field, newValue);
// Update the parent component with the response data
// The backend now returns the full FeChar with categorizedskills
this.$emit('character-data-updated', response.data);
this.$emit('character-updated');
this.cancelEditSkill();
} catch (error) {
console.error('Failed to update skill:', error);
alert('Fehler beim Speichern: ' + (error.response?.data?.error || error.message));
this.cancelEditSkill();
// Revert changes on error by reloading
this.$emit('character-updated');
}
},
@@ -799,6 +868,7 @@ export default {
this.editingSkillId = null;
this.editingField = null;
this.editValue = '';
this.showNameEditWarning = false;
},
isEditingSkill(skill, field) {
@@ -129,6 +129,7 @@
{{ $t('spell.category') }}
<button @click="sortBy('category')">{{ sortField === 'category' ? (sortAsc ? '' : '') : '-' }}</button>
</th>
<th class="cd-table-header">{{ $t('spell.learning_category') || 'Learning Category' }}</th>
<th class="cd-table-header">
{{ $t('spell.name') }}
<button @click="sortBy('name')">{{ sortField === 'name' ? (sortAsc ? '' : '') : '-' }}</button>
@@ -150,7 +151,7 @@
<tbody>
<tr v-if="creatingNew">
<td>New</td>
<td colspan="14">
<td colspan="15">
<div class="edit-form">
<div class="edit-row">
<div class="edit-field">
@@ -165,6 +166,15 @@
</option>
</select>
</div>
<div class="edit-field">
<label>{{ $t('spell.learning_category') || 'Learning Category' }}:</label>
<select v-model="newItem.learning_category" style="width:150px;">
<option value="">-</option>
<option v-for="category in availableLearnCategories" :key="category" :value="category">
{{ category }}
</option>
</select>
</div>
<div class="edit-field">
<label>{{ $t('spell.level') }}:</label>
<input v-model.number="newItem.level" type="number" style="width:60px;" />
@@ -248,6 +258,7 @@
<tr v-if="editingIndex !== index">
<td>{{ dtaItem.id || '' }}</td>
<td>{{ dtaItem.category|| '-' }}</td>
<td>{{ dtaItem.learning_category || '-' }}</td>
<td>{{ dtaItem.name || '-' }}</td>
<td>{{ dtaItem.level || '0' }}</td>
<td>{{ dtaItem.ap || '0' }}</td>
@@ -267,7 +278,7 @@
<!-- Edit Mode -->
<tr v-else>
<td><input v-model="editedItem.id" style="width:20px;" disabled /></td>
<td colspan="14">
<td colspan="15">
<!-- Expanded edit form -->
<div class="edit-form">
<div class="edit-row">
@@ -283,6 +294,15 @@
</option>
</select>
</div>
<div class="edit-field">
<label>{{ $t('spell.learning_category') || 'Learning Category' }}:</label>
<select v-model="editedItem.learning_category" style="width:150px;">
<option value="">-</option>
<option v-for="category in availableLearnCategories" :key="category" :value="category">
{{ category }}
</option>
</select>
</div>
<div class="edit-field">
<label>{{ $t('spell.level') }}:</label>
<input v-model.number="editedItem.level" type="number" style="width:60px;" />
@@ -519,6 +539,7 @@ export default {
filterQuelle: '',
enhancedSpells: [],
availableSources: [],
availableLearnCategories: [],
gameSystems: [],
selectedSystemId: null,
creatingNew: false,
@@ -663,6 +684,7 @@ export default {
const response = await API.get('/api/maintenance/spells-enhanced')
this.enhancedSpells = response.data.spells || []
this.availableSources = response.data.sources || []
this.availableLearnCategories = response.data.learnCategories || []
// Also update mdata for compatibility
if (response.data.categories) {
this.mdata.spellcategories = response.data.categories
@@ -725,6 +747,7 @@ export default {
this.newItem = {
name: '',
category: this.mdata.spellcategories?.[0] || '',
learning_category: '',
level: 0,
ap: '',
zauberdauer: '',
+3 -1
View File
@@ -177,6 +177,7 @@ export default {
spell:{
id:'ID',
category:'Kategorie',
learning_category:'Lernkategorie',
name:'Name',
description:'Beschreibung',
level:'Stufe',
@@ -563,7 +564,8 @@ export default {
bRollTooltip: 'B würfeln: 1d6 + Modifikator je nach Rasse'
},
datasheet: {
editHelp: 'Doppelklicken auf ein Feld, um es zu bearbeiten.'
editHelp: 'Doppelklicken auf ein Feld, um es zu bearbeiten.',
editnamewarning: 'Wenn der Name der Fertigkeit geändert wird kann diese nicht mehr nach den Regeln verbessert werden. Auch beim Export können Boni und andere Werte fehlen!'
}
},
audit: {
+3 -1
View File
@@ -173,6 +173,7 @@ export default {
spell:{
id:'ID',
category:'Category',
learning_category:'Learning Category',
name:'Name',
description:'Description',
level:'Level',
@@ -559,7 +560,8 @@ export default {
bRollTooltip: 'Roll B: 1d6 + modifier based on race'
},
datasheet: {
editHelp: 'Click twice on a field to edit it.'
editHelp: 'Click twice on a field to edit it.',
editnamewarning: 'If the name of the skill is changed, it can no longer be improved according to the rules. Also, bonuses and other values may be missing when exporting!'
}
},
audit: {
+26 -5
View File
@@ -1,12 +1,33 @@
import axios from 'axios'
import { getAPIBaseURL } from './config'
const API = axios.create({
baseURL: import.meta.env.VITE_API_URL || 'https://bamort-api.trokan.de', // Use env variable with fallback
})
// Create API instance without baseURL - will be set dynamically
const API = axios.create({})
// Request interceptor to add auth token
let baseURLPromise = null
let baseURLResolved = false
// Get base URL (cached after first call)
async function ensureBaseURL() {
if (baseURLResolved) {
return
}
if (!baseURLPromise) {
baseURLPromise = getAPIBaseURL()
}
const baseURL = await baseURLPromise
API.defaults.baseURL = baseURL
baseURLResolved = true
}
// Request interceptor to add auth token and ensure baseURL is set
API.interceptors.request.use(
(config) => {
async (config) => {
// Ensure baseURL is set before request
await ensureBaseURL()
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
+134
View File
@@ -0,0 +1,134 @@
/**
* Runtime configuration for BaMoRT frontend
*
* For desktop builds (Wails), reads the API base URL from the Go backend
* to allow runtime configuration via .env without rebuilding.
*
* For web builds, uses runtime config.json or auto-detection:
* 1. Try to load /config.json (can be modified at deployment without rebuild)
* 2. Try to detect backend at same origin (production reverse proxy setup)
* 3. Fall back to VITE_API_URL (development) or same origin
*/
let cachedAPIBaseURL = null
/**
* Try to load configuration from /config.json
*/
async function loadConfigFile() {
try {
const response = await fetch('/config.json', {
cache: 'no-cache',
headers: { 'Accept': 'application/json' }
})
if (response.ok) {
// Check if response is actually JSON (not HTML from SPA fallback)
const contentType = response.headers.get('content-type')
if (contentType && contentType.includes('application/json')) {
const config = await response.json()
if (config.apiBaseURL) {
console.log('Loaded API URL from config.json:', config.apiBaseURL)
return config.apiBaseURL
}
}
}
} catch (error) {
// config.json doesn't exist or is invalid, that's okay
}
return null
}
/**
* Try to detect backend at same origin (production setup)
*/
async function detectBackendAtOrigin() {
try {
const origin = window.location.origin
const response = await fetch(`${origin}/api/public/version`, {
method: 'GET',
cache: 'no-cache',
signal: AbortSignal.timeout(2000) // 2 second timeout
})
if (response.ok) {
console.log('Detected backend at same origin:', origin)
return origin
}
} catch (error) {
// Backend not at same origin, that's okay
}
return null
}
/**
* Get the API base URL dynamically
* - In Wails desktop app: calls Go backend to get configured URL
* - In web app: tries config.json, auto-detection, or VITE_API_URL
*/
export async function getAPIBaseURL() {
// Return cached value if available
if (cachedAPIBaseURL) {
return cachedAPIBaseURL
}
// Try Wails desktop app first (with timeout)
if (typeof window !== 'undefined' && window['go']) {
// Wait up to 3 seconds for Wails bindings to be ready
for (let i = 0; i < 30; i++) {
try {
if (window['go']?.['main']?.['App']?.['GetAPIBaseURL']) {
const url = await window['go']['main']['App']['GetAPIBaseURL']()
cachedAPIBaseURL = url
console.log('Desktop app using API URL from config:', url)
return url
}
} catch (error) {
console.error('Failed to get API URL from Wails:', error)
break
}
await new Promise(resolve => setTimeout(resolve, 100))
}
// Wails detected but binding failed - use desktop fallback
cachedAPIBaseURL = 'http://localhost:8185'
console.log('Desktop app using fallback:', cachedAPIBaseURL)
return cachedAPIBaseURL
}
// Web app - try multiple strategies
// Strategy 1: Load from config.json (can be modified at deployment)
const configFileURL = await loadConfigFile()
if (configFileURL) {
cachedAPIBaseURL = configFileURL
return cachedAPIBaseURL
}
// Strategy 2: Check if backend is at same origin (production reverse proxy)
if (window.location.hostname !== 'localhost' && window.location.hostname !== '127.0.0.1') {
const sameOriginURL = await detectBackendAtOrigin()
if (sameOriginURL) {
cachedAPIBaseURL = sameOriginURL
return cachedAPIBaseURL
}
}
// Strategy 3: Use VITE_API_URL (development) or fallback to same origin
if (import.meta.env.VITE_API_URL) {
cachedAPIBaseURL = import.meta.env.VITE_API_URL
console.log('Web app using VITE_API_URL:', cachedAPIBaseURL)
} else {
// Final fallback: assume same origin for production
cachedAPIBaseURL = window.location.origin
console.log('Web app using same origin:', cachedAPIBaseURL)
}
return cachedAPIBaseURL
}
/**
* Check if running in desktop mode (Wails)
*/
export function isDesktopMode() {
return isWailsApp()
}typeof window !== 'undefined' &&
window['go']?.['main']?.['App'] !== undefined
+2 -1
View File
@@ -44,6 +44,7 @@
</style>
<script>
import { getAPIBaseURL } from '../utils/config'
import axios from 'axios'
import { getVersion, getGitCommit } from '../version'
@@ -80,7 +81,7 @@ export default {
methods: {
async fetchBackendVersion() {
try {
const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:8180'
const apiUrl = await getAPIBaseURL()
const response = await axios.get(`${apiUrl}/api/public/version`)
if (response.data) {
+2 -1
View File
@@ -115,6 +115,7 @@
</template>
<script>
import { getAPIBaseURL } from '../utils/config'
import axios from 'axios'
import { getVersion, getGitCommit } from '../version'
@@ -157,7 +158,7 @@ export default {
methods: {
async fetchBackendVersion() {
try {
const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:8180'
const apiUrl = await getAPIBaseURL()
const response = await axios.get(`${apiUrl}/api/public/systeminfo`)
if (response.data) {
+4
View File
@@ -0,0 +1,4 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
export function GetAPIBaseURL():Promise<string>;
+7
View File
@@ -0,0 +1,7 @@
// @ts-check
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
export function GetAPIBaseURL() {
return window['go']['main']['App']['GetAPIBaseURL']();
}