55 KiB
BaMoRT Frontend — Comprehensive Code Analysis
Generated: 2026-03-13
Table of Contents
- Project Setup
- Application Entry
- Router
- Pinia Stores
- Views (src/views/)
- Components (src/components/)
- API Layer
- i18n
- Styling
- Business Logic in Frontend
- User Flows
- Component Hierarchy
1. Project Setup
package.json
name: bamort-frontend
version: 0.2.3
type: module
Runtime dependencies:
| Package | Version | Purpose |
|---|---|---|
vue |
^3.5.13 | Core framework (Options API) |
vue-router |
^4.5.0 | Client-side routing |
pinia |
^2.3.0 | State management |
vue-i18n |
^11.0.0 | Internationalisation (DE + EN) |
axios |
^1.7.9 | HTTP client |
Dev dependencies:
| Package | Version | Purpose |
|---|---|---|
vite |
^6.0.1 | Build tool / dev server |
@vitejs/plugin-vue |
^5.2.1 | Vite Vue 3 plugin |
vite-plugin-vue-devtools |
^7.6.5 | Browser devtools integration |
No testing framework, no TypeScript compiler (.ts files exist only as type declaration stubs).
vite.config.js
export default defineConfig({
plugins: [vue(), vueDevTools()],
resolve: {
alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) }
},
server: {
host: ['bamort.trokan.de', '192.168.0.48', 'localhost', 'terra.local'],
}
})
Key notes:
@alias maps tosrc/- Dev server explicitly bound to multiple hostnames (network-accessible)
- Minification and source maps are commented out (disabled)
- No Vite proxy configuration — all API calls go directly to the configured
VITE_API_URL
index.html
Single-page app root. Mounts #app and loads src/main.js as an ES module. Uses favicon.png.
src/version.js
export const VERSION = '0.2.3'
export const GIT_COMMIT = import.meta.env.VITE_GIT_COMMIT || 'unknown'
Exposes getVersion() and getGitCommit() consumed by LandingView and SystemInfoView.
2. Application Entry
src/main.js
import './assets/main.css'
import { createApp } from "vue"
import { createPinia } from 'pinia'
import App from "./App.vue"
import router from "./router"
import { i18n } from './stores/languageStore'
import UtilsPlugin from './utils/utilsPlugin'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.use(i18n)
app.use(UtilsPlugin)
app.mount("#app")
Order of registration:
- Pinia (state store)
- Vue Router
- vue-i18n instance (imported from
languageStore.js, not created here) UtilsPlugin(global properties: date helpers + dice rollers)
src/App.vue
Root component. Shows <Menu /> only when the user is logged-in (detected via localStorage.getItem('token')). The main <router-view /> is wrapped in a <main> that gets the CSS class full-width when the user is not logged in (centering forms on the page). Listens to storage and custom auth-changed window events to reactively update loggedIn.
UtilsPlugin (src/utils/utilsPlugin.js)
Installs these global properties on the Vue app instance:
| Property | Source | Description |
|---|---|---|
$formatDate |
dateUtils.js |
Format ISO date to locale string |
$formatDateTime |
dateUtils.js |
Format ISO datetime |
$formatRelativeDate |
dateUtils.js |
Relative time (e.g. "2 hours ago") |
$safeValue |
dateUtils.js |
Return value or fallback |
$capitalize |
dateUtils.js |
Capitalize string |
$rollDie |
randomUtils.js |
Roll single die (1–N) |
$rollDice |
randomUtils.js |
Roll multiple dice → array |
$rollDiceWithSum |
randomUtils.js |
Roll + return sum |
$rollNotation |
randomUtils.js |
Parse RPG notation e.g. "3d6+2" |
$randomBetween |
randomUtils.js |
Random int in range |
$randomChoice |
randomUtils.js |
Pick random element from array |
$shuffleArray |
randomUtils.js |
Fisher-Yates shuffle |
3. Router
src/router/index.js — createWebHistory (HTML5 mode, no hash).
Route Table
| Path | Name | Component | Auth? | Admin? |
|---|---|---|---|---|
/ |
Landing |
LandingView |
— | — |
/login |
Login |
LoginView |
— | — |
/register |
Register |
RegisterView |
— | — |
/forgot-password |
ForgotPassword |
ForgotPasswordView |
— | — |
/reset-password |
ResetPassword |
ResetPasswordView |
— | — |
/dashboard |
Dashboard |
DashboardView |
✓ | — |
/profile |
UserProfile |
UserProfileView |
✓ | — |
/users |
UserManagement |
UserManagementView |
✓ | ✓ |
/ausruestung/:characterId |
Ausruestung |
AusruestungView |
✓ | — |
/maintenance |
Maintenance |
MaintenanceView |
✓ | — |
/upload |
FileUpload |
FileUploadPage |
✓ | — |
/sponsors |
Sponsors |
SponsorsView |
— | — |
/help |
Help |
HelpView |
— | — |
/system-info |
SystemInfo |
SystemInfoView |
— | — |
/character/:id |
CharacterDetails |
CharacterDetails |
✓ | — |
/character/create/:sessionId |
CharacterCreation |
CharacterCreation |
✓ | — |
Static imports (loaded immediately — needed for unauthenticated pages): LandingView, LoginView, RegisterView, ForgotPasswordView, ResetPasswordView.
Lazy-loaded (code-split into separate chunks on first access): all other views and both character components.
Route params are passed as props for CharacterDetails (id) and CharacterCreation (sessionId).
Navigation Guard
router.beforeEach(async (to, from, next) => {
if (to.meta.requiresAuth && !isLoggedIn()) {
next({ name: "Login" })
} else if (to.meta.requiresAdmin) {
const userStore = useUserStore()
if (!userStore.currentUser) await userStore.fetchCurrentUser()
if (!userStore.isAdmin) next({ name: "Dashboard" })
else next()
} else {
next()
}
})
isLoggedIn() simply checks localStorage.getItem("token"). The admin check lazy-loads the user profile if it hasn't been fetched yet.
4. Pinia Stores
userStore (src/stores/userStore.js)
defineStore('user', {
state: () => ({ currentUser: null, isLoading: false }),
getters: {
isAuthenticated: state => !!state.currentUser,
userRole: state => state.currentUser?.role || 'standard',
isAdmin: state => state.currentUser?.role === 'admin',
isMaintainer: state => state.currentUser?.role === 'maintainer'
|| state.currentUser?.role === 'admin',
isStandardUser: state => !!state.currentUser
},
actions: {
async fetchCurrentUser() { /* GET /api/user/profile */ },
clearUser() { this.currentUser = null }
}
})
- Fetches profile from
GET /api/user/profile. - On successful fetch, also updates the i18n locale from
profile.preferred_languageand persists it tolocalStorage. currentUsershape:{ id, username, display_name, email, role, preferred_language }.- Used by:
Menu.vue,CharacterDetails.vue,UserManagementView.vue, router guard.
languageStore (src/stores/languageStore.js)
export const i18n = createI18n({
legacy: false,
locale: localStorage.getItem('language') || 'de',
fallbackLocale: 'en',
messages: { de, en }
})
defineStore('language', {
state: () => ({ currentLanguage: localStorage.getItem('language') || 'de' }),
actions: {
setLanguage(lang) {
this.currentLanguage = lang
i18n.global.locale.value = lang
localStorage.setItem('language', lang)
}
}
})
Unusually, the i18n instance is created and exported from this file, then imported in main.js. Default language: de.
characterCreationStore (src/stores/characterCreation.js)
Manages the multi-step character wizard session state in-memory (backed by server-side session).
State:
{
sessionData: {
basic_info: null,
attributes: null,
derived_values: null,
skills: [],
skills_meta: { totalUsedPoints: 0, selectedCategory: null },
spells: [],
equipment: null
},
currentStep: 1, // 1–5
sessionId: null,
isLoading: false,
error: null
}
Getters: characterClass, characterStand, characterRace, hasSelectedSkills, totalSkillPoints, isValid (checks basic_info + attributes + derived_values are all non-null).
Actions: initializeSession(sessionId?), createNewSession(), loadSession(sessionId) (TODO), saveSession() (TODO), updateStepData(data), goToStep(n), nextStep(), previousStep(), clearSession().
Note: saveSession() and loadSession() are marked TODO — the actual session persistence is handled directly by CharacterCreation.vue via the API, bypassing this store.
counter (src/stores/counter.ts)
A boilerplate Pinia counter store from Vue scaffolding. Not used in the application.
5. Views (src/views/)
Most views are thin wrappers that delegate to a single component.
LandingView.vue
Purpose: Public landing page. Displays app logo, description, version info, and action links.
Data fetched: GET ${VITE_API_URL}/api/public/version (unauthenticated, uses raw axios not the API utility). Retries every 5 s up to 24 times if the backend is unreachable.
Logic:
isBackendAvailablecomputed: disables the login button until the backend responds.- Clears retry interval on
beforeUnmount. - Shows frontend version from
version.js(0.2.3) and backend version from API.
Components used: None (self-contained).
LoginView.vue
Thin wrapper: renders <LoginForm />.
RegisterView.vue
Thin wrapper: renders <RegisterForm />.
ForgotPasswordView.vue
Thin wrapper: renders <ForgotPasswordForm />.
ResetPasswordView.vue
Thin wrapper: renders <ResetPasswordForm />.
DashboardView.vue
Thin wrapper: renders <CharacterList />.
CharacterView.vue
Thin wrapper (legacy): passes $route.params.characterId to <CharacterDetails /> as :characterId. Route /character/:id uses CharacterDetails directly via lazy import; this view appears unused by the router.
AusruestungView.vue
Passes $route.params.characterId to <AusruestungList />. Legacy/simple standalone equipment list for a character — separate from the main character detail equipment tab.
MaintenanceView.vue
Thin wrapper: renders <Maintenance />.
UserProfileView.vue
Purpose: Self-service profile management for the logged-in user.
API calls:
GET /api/user/profile(on created)PUT /api/user/display-name— change display name (max 30 chars)PUT /api/user/language— change preferred language (updates i18n locale + localStorage)PUT /api/user/email— change emailPUT /api/user/password— change password (requires current + new + confirm)
Validation:
- Display name: max 30 chars
- Password: min 6 chars, must match confirmation
- Email: must differ from current, type="email"
- Duplicate email check via backend error message parsing
Sections rendered: User info display, Change Display Name, Change Language, Change Email, Change Password.
After language update: directly sets this.$i18n.locale and localStorage.language. Also updates userStore.currentUser.display_name after display name change.
UserManagementView.vue
Purpose: Admin panel for managing all users.
Guard: Route has requiresAdmin: true.
API calls:
GET /api/users— list all usersPUT /api/users/:id/role— change role (standard|maintainer|admin)DELETE /api/users/:id— delete userPUT /api/users/:id/password— admin password reset (no current password required)
Dialogs (modal overlays):
- Role change dialog — select from standard/maintainer/admin
- Delete confirmation dialog
- Password change dialog (min 6 chars, must confirm)
Protection: Buttons disabled for the current logged-in user's own row (cannot self-demote or self-delete). Detected via currentUser.id === user.id from userStore.
FileUploadPage.vue
Purpose: Import character data from VTT (Virtual Tabletop) JSON or CSV files.
API call: POST /api/importer/upload with multipart/form-data.
Validation: Checks MIME types (application/json, text/csv). VTT file is required; CSV is optional.
Note: Manually adds Authorization header even though the API interceptor already does this — a redundancy.
HelpView.vue
Static content page. Dynamically loads FAQ items from i18n keys help.faq1.question/.answer, help.faq2..., etc., iterating until a key is not found using $te().
SponsorsView.vue
Static content page listing contributors and special thanks. Links to GitHub and Ko-fi.
SystemInfoView.vue
Purpose: Displays system/technology information and live backend status.
API call: GET ${VITE_API_URL}/api/public/systeminfo (uses raw axios). Returns { version, gitCommit, userCount, charCount, dbVersion }.
Technology stack display: Hardcoded lists of Vue 3, Vite, Go 1.25, Gin, GORM, MariaDB, JWT, chromedp, Docker.
6. Components (src/components/)
Menu.vue
Purpose: Fixed top navigation bar (60px high, z-index 1000).
Authentication-dependent rendering:
- Always visible: Home, Info dropdown (Help, Sponsors, System Info)
- Logged-in only: Characters dropdown (Dashboard, Import Data)
- Maintainer/Admin only: Admin dropdown (Maintenance, User Management)
- Not logged-in only: Register link
- Right side (logged-in): User icon dropdown (Profile, Logout)
Hover dropdowns: Implemented with @mouseenter/@mouseleave + 200 ms setTimeout delay to prevent premature closing.
Logout flow: calls logout() from auth.js (removes token), clears userStore, dispatches auth-changed window event, navigates to /.
Auth change listener: window.addEventListener('auth-changed', ...) — re-fetches user profile on login or clears on logout.
Props/emits: None.
LoginForm.vue
Purpose: Login form.
API call: POST /login with { username, password }. On success: stores JWT in localStorage, calls userStore.fetchCurrentUser(), dispatches auth-changed, navigates to /dashboard.
Error handling: Displays "Invalid credentials" on any error.
Note: Hardcoded English labels — does not use $t() for the form labels (unlike most other components).
RegisterForm.vue
Purpose: Registration form.
Validation: Client-side password confirmation match check before API call.
API call: POST /register with { username, password, email }.
i18n: Fully translated via auth.* keys.
ForgotPasswordForm.vue
Purpose: Request a password reset email.
API call: POST /password-reset/request with { email, redirect_url: window.location.origin }.
UX: Shows success state (hides form, shows info text) on success without redirecting. Shows error message on failure.
ResetPasswordForm.vue
Handles the password-reset token link (not examined in detail — delegates to <ResetPasswordForm />).
CharacterList.vue
Purpose: Dashboard character listing with creation session management.
API calls:
GET /api/characters→{ self_owned: [...], others: [...] }GET /api/characters/create-sessions→{ sessions: [...] }POST /api/characters/create-session→{ session_id }— starts new character wizardDELETE /api/characters/create-session/:sessionId
Layout:
- "Create New Character" button (green dashed border)
<CharacterCreationSessions>sub-component (shows in-progress wizards)- Two column layout: owned characters | shared characters
- Each character shows: name, race, class, grade, owner, public/private badge
- Character names are
<router-link>to/character/:id
Props/emits: None (standalone).
Sub-components: CharacterCreationSessions.
CharacterCreationSessions.vue
Purpose: Shows active character creation sessions (drafts) on the dashboard.
Props: sessions: Array
Emits: continue-session(sessionId), delete-session(sessionId)
Renders: Grid of cards, one per session. Shows name, step progress (e.g. "Step 2/5"), race/class, last updated, expires. Clicking a card card navigates via continue-session emit.
CharacterDetails.vue
Purpose: Main character management hub. Fetches character data and dynamically renders sub-views through a tab-like submenu.
Props: id (string, from route)
API calls:
GET /api/characters/:id(on created + after any edit viarefreshCharacter)
Computed: isOwner — compares character.user_id with userStore.currentUser.id.
Sub-views rendered via <component :is="currentView"> with character and isOwner props:
DatasheetView(default)SkillViewWeaponViewSpellViewEquipmentViewExperianceViewDeleteCharView
Additional dialogs:
- 📄 Export button →
ExportDialog - 🌐/🔒 Visibility button (owner only) →
VisibilityDialog
Event handling: @character-updated from sub-views triggers refreshCharacter() which reloads from API.
DatasheetView.vue
Purpose: Character stats and biographical information display with inline editing.
Props: character: Object (required)
Emits: character-updated
Sub-components: ImageUploadCropper
Inline editing mechanism: Double-click (@dblclick) on any displayed value activates an input/select in its place. On blur or Enter, saveEdit(path) / saveProp(prop) PATCHes the character via PUT /api/characters/:id. On Escape, cancelEdit() restores display.
Stat fields (left column stat box):
| Label key | Character path |
|---|---|
stats.beauty |
eigenschaften.0.value |
stats.dexterity |
eigenschaften.1.value |
stats.agility |
eigenschaften.2.value |
stats.intelligence |
eigenschaften.3.value |
stats.constitution |
eigenschaften.4.value |
stats.charisma |
eigenschaften.5.value |
stats.strength |
eigenschaften.6.value |
stats.willpower |
eigenschaften.7.value |
stats.spelltalent |
eigenschaften.8.value |
stats.poisontolerance |
git |
stats.movement |
b.max |
stats.lifepoints |
lp.max |
stats.staminapoints |
ap.max |
stats.divinegrace |
bennies.gg |
stats.fatesfavor |
bennies.sg |
Character info fields (editable): name, typ (class), gender (select), grad, rasse (select), origin (select), social_class (select), spezialisierung (select), alter, hand (select: rechts/links/beidhändig), groesse, gewicht, merkmale.groesse, merkmale.breite, merkmale.augenfarbe, merkmale.haarfarbe, glaube (multi-select), merkmale.sonstige.
Select options: loaded lazily via GET /api/characters/:id/datasheet-options (only when a select-type field is first double-clicked).
glaube (beliefs) field: Multi-select stored as comma-separated string; displayed and edited as array.
Path traversal for saving: path.split('.') reduces into nested character object, then PUT /api/characters/:id with full character payload.
SkillView.vue
Purpose: Display and manage character skills (Fertigkeiten + Waffenfertigkeiten) and innate skills.
Props: character: Object (required), isOwner: Boolean
Emits: character-updated
Sub-components: SkillImproveDialog, SkillLearnDialog
Learning mode toggle: 🎓 button visible to owner. When active:
- Resources bar (EP + Gold) shown
- Additional action buttons: 📚 Learn New, ➕ Add Skill
- Each skill row shows ⬆️ Improve button
Table structure:
- Regular skills: grouped by category (
character.categorizedskills) — name, EW (Fertigkeitswert), bonus, PP (Praxispunkte), note - Weapon skills: from
character.waffenfertigkeiten - Innate skills: from
character.InnateSkills(right column, name + value only)
PP (Praxispunkte) adjustment: +/− buttons directly call POST /api/characters/practice-points.
Inline skill editing: Double-click on name, fertigkeitswert, or bemerkung → inline input; saved via PUT /api/characters/:skill_id/skill.
Dialogs:
SkillLearnDialog— learn new skill (complex, see below)- Improve Selection Dialog — simple modal; selects skill + PP count →
POST /api/characters/improve-skill - Add Skill Dialog — manually add skill by name/value →
POST /api/characters/:id/add-skill SkillImproveDialog— detailed multi-level improvement dialog
SkillImproveDialog.vue
Purpose: Detailed skill improvement dialog supporting multiple reward types and level sequences.
Props: character: Object, skill: Object, isVisible: Boolean, learningType: 'improve'|'learn'|'spell'
Emits: close, skill-updated
Features:
- Loads available improvement levels + costs from
GET /api/characters/lerncost-newsystemor similar - Loads reward types from API
- Shows live resource tracking (EP remaining, Gold remaining, PP remaining)
- Multi-level selection (click/deselect in sequence — must be consecutive)
- Reward types:
default(EP + Gold),noGold(EP only),halveepnoGold(half EP),pp(PP only),mixed(EP + PP) - Submits via
POST /api/characters/improve-skill-newsystemor similar
SkillLearnDialog.vue
Purpose: Learn a brand-new skill (not yet on the character).
Features:
- Drag & drop (HTML5 native) or click-to-select skills from available list to "to learn" list
- Category filter buttons (all categories shown as chips)
- Search filter input
- Sort by name or EP cost (ascending/descending toggle)
- Reward type selector: Standard (EP + Gold) or Nur EP (no gold)
- Live EP/Gold remaining display
- Multi-skill batch learning (multiple skills can be queued)
- Submits batch via
POST /api/characters/learn-skills(or similar)
SpellView.vue
Purpose: Display character spells.
Props: character: Object, isOwner: Boolean
Emits: character-updated
Sub-components: SpellLearnDialog
Table: name, description, bonus, source.
Learning: Owner sees 🎓 button → opens SpellLearnDialog.
SpellLearnDialog.vue
Purpose: Learn new spells with filtering, sorting, and multi-spell queuing.
Features:
- School (Schule) filter buttons
- Search term filter
- Sort by name, EP cost, Gold cost
- Reward type selector (loads from API)
- Available/selected two-column layout
- Per-spell affordability check
- Batch spell learning queue with total cost summary
- Submits via
POST /api/characters/learn-spells(or similar)
WeaponView.vue
Purpose: Display and manage character weapons.
Props: character: Object, isOwner: Boolean
Emits: character-updated
Table: name (* suffix if magical), description, weight, value, amount, contained_in, bonus (anb/abwb).
Add weapon dialog:
- Loads master weapon list from
GET /api/maintenance/weapons(or similar) - Text search filter
- Select + set amount
- Submits via
POST /api/equipment(or similar weapon endpoint)
Edit weapon dialog:
- Inline form for: description, is_magical checkbox, amount, value, attack bonus (anb), defense bonus (abwb), damage bonus (schb)
- Saves via
PUT /api/equipment/:id(or similar)
Delete weapon: DELETE /api/equipment/:id with confirm dialog using $t('weapon.confirmDelete') with {name} interpolation.
EquipmentView.vue
Purpose: Display and manage character non-weapon equipment.
Props: character: Object, isOwner: Boolean
Emits: character-updated
Table: name, description, weight, value, amount, contained_in, bonus.
Add equipment dialog:
- Loads master equipment from
GET /api/maintenance/equipment - Text search filter
- Select + set amount
- Submits:
POST /api/equipmentwith{ character_id, name, beschreibung, gewicht, wert, anzahl, bonus, beinhaltet_in, contained_in, container_type }
Delete: DELETE /api/equipment/:id with i18n confirm using {name} interpolation.
ExperianceView.vue
Purpose: Manage EP (experience points) and gold/wealth, with audit log.
Props: character: Object, isOwner: Boolean
Sub-components: AuditLogView
Computed: totalWealthInGS — currency conversion:
goldstücke + Math.floor(silberstücke / 10) + Math.floor(kupferstücke / 10)
(1 GS = 10 SS = 10 KS — Midgard RPG currency system)
EP management: Add/remove EP inputs → PUT /api/characters/:id/experience or similar.
Gold management: Add/remove gold inputs → PUT /api/characters/:id/wealth or similar.
After both operations: calls this.$refs.auditLog.loadAuditLog() to refresh the audit log.
AuditLogView.vue
Purpose: Display change history (EP/gold transactions) for a character.
Props: character: Object
API: GET /api/characters/:id/audit-log with query params for field filter and date range.
Filters:
- Field: all / experience_points / gold / silver / copper
- Date range: all time / today / this week / this month / custom (from–to date inputs)
- Group by date toggle
Statistics section: total_changes, ep_spent, ep_gained, gold_spent, gold_gained.
Entry display: Shows old → new value with difference (+/−), reason, optional notes. Color-coded: green for positive, red for negative changes.
ExportDialog.vue
Purpose: Multi-format character export modal.
Props: characterId, showDialog: Boolean
Emits: update:showDialog, export-success
Computed: canExport — false if no format selected, or PDF format but no template selected.
On created: loads templates from GET /api/pdf/templates.
Export formats:
| Format | API Call | Delivery |
|---|---|---|
GET /api/pdf/export/:id?template=X&showUserName=true → returns { filename }, then opens GET /api/pdf/file/:filename in new tab |
Browser window popup | |
| VTT | GET /api/importer/export/vtt/:id/file (blob) |
Programmatic download link |
| BaMoRT (JSON) | GET /api/transfer/download/:id (blob) |
Programmatic download link |
PDF export note: Gets filename from API first, then constructs full URL from API.defaults.baseURL + /api/pdf/file/:filename. Uses window.open(url, '_blank') synchronously (not before an async call) — avoids popup blockers per project convention.
VisibilityDialog.vue
Purpose: Change character visibility (private/public) and manage per-user sharing.
Props: characterId, currentVisibility: Boolean, showDialog: Boolean
Emits: update:showDialog, visibility-updated(isPublic)
Watches: showDialog — on open, loads available users and current shares.
API calls:
GET /api/characters/:id/available-users— users eligible to share withGET /api/characters/:id/shares(implied) — currently shared usersPUT /api/characters/:id/visibility+ share update
Features: Radio buttons for private/public. Searchable user list (filter by display name, username, email). Two-panel: "Add Users" | "Currently Shared With" with remove buttons.
DeleteCharView.vue
Purpose: Delete character confirmation panel (inside character detail submenu).
Props: character: Object, isOwner: Boolean
Emits: deleted, cancel
Behavior: Shows "not authorized" message for non-owners. For owners: confirmation text + "Yes, Delete" button → DELETE /api/characters/:id → navigates to /dashboard.
ImageUploadCropper.vue
Purpose: Upload and crop a character portrait image.
Props: characterId
Emits: image-updated
Features:
- File picker restricted to
image/* - HTML5 Canvas cropping with mouse drag selection
- Two crop shapes: rectangular or circular
- Live preview canvas (400×400)
POST /api/characters/:id/imagewith FormData containing crop coordinates
Implementation: Uses FileReader → Image → two <canvas> elements (source + preview). Mouse events track drag selection rectangle. On save, crops the selection to a new canvas and uploads as FormData.
Maintenance.vue
Purpose: Master data management hub for game system admins/maintainers.
API call: GET /api/maintenance — loads all master data at once into mdata object.
mdata shape: { skills, skillcategories, weaponskills, spells, spellcategories, equipment, weapons }
Sub-views (selected via bottom submenu):
maintenance/SkillView(default)maintenance/SpellViewmaintenance/EquipmentViewmaintenance/WeaponViewmaintenance/WeaponSkillViewmaintenance/BelieveViewmaintenance/GameSystemViewmaintenance/LitSourceViewmaintenance/MiscLookupViewmaintenance/SkillImprovementCostView
All sub-views receive mdata as prop.
maintenance/SkillView.vue
Purpose: CRUD for skill master data.
Features:
- Full text search + multi-field filters (category, difficulty, improvable, innate, bonus property)
- Sortable table (name, category columns)
- Inline row editing (click row → edit form appears in table)
- Create new skill inline
- API:
POST /api/maintenance/skills,PUT /api/maintenance/skills/:id,DELETE /api/maintenance/skills/:id
(Other maintenance views follow the same pattern for their respective domains.)
CharacterCreation.vue
Purpose: Multi-step character creation wizard.
Props: sessionId: String (required, from route)
5-step wizard:
CharacterBasicInfo— name, origin, belief, gender, race, class, social classCharacterAttributes— 7 core attributes (1–100 range, roll support)CharacterDerivedValues— calculated + adjustable secondary statsCharacterSkills— point-buy skill selection with category budgetsCharacterSpells— spell selection → finalize character
API calls:
GET /api/characters/create-session/:sessionId— load session stateGET /api/characters/skill-categories— available skill categories with point budgets- Step-specific save:
PUT /api/characters/create-session/:id/basic-infoetc. POST /api/characters/create— finalize (step 5)DELETE /api/characters/create-session/:id— delete draft
Session persistence: Each handleNext(data) saves the updated step data to the backend before incrementing currentStep. Session reloaded after each save to ensure consistent state.
Expiry info: Session expiry date shown at bottom. "Delete Draft" button available throughout.
CharacterCreation/CharacterBasicInfo.vue
Purpose: Step 1 of the wizard. Collects fundamental character identity.
Fields: Name (2–50 chars, required), Origin (select from API list), Belief/Glaube (typeahead search from API, minimum 2 chars to activate), Gender (male/female), Race (select), Class/Typ (select), Social Class (select or dice roll).
Dice roll for social class: 🎲 button (requires class to be selected first). Rolls 1d100 with class-specific modifier and maps to social class tier. Shows result in overlay that dismisses on click.
Races: loaded from backend (API call for character creation options).
Emits: next(formData), save(formData)
Validation: isValid computed checks all required fields are non-empty.
CharacterCreation/CharacterAttributes.vue
Purpose: Step 2 — set the 7 base attributes (St, Gs, Gw, Ko, In, Zt, Au) each 1–100.
Features:
- Individual roll buttons using
max(2d100)formula per attribute (except Au) - 🎲 "Roll All" button
- Race-specific Beauty (Au) restrictions: Elves ≥ 81, Gnomes/Dwarves ≤ 80
- Shows total points sum and average
- Disabled state display (shows "ENABLED"/"DISABLED" status indicator)
CharacterCreation/CharacterDerivedValues.vue
Purpose: Step 3 — calculate and optionally override secondary stats.
Fields: PA (personal charisma), WK (willpower), LP max (life points), AP max (adventure points), B max (movement), resistances, defense, bonuses, SG (fate's favor), GG (divine grace), GP (luck points).
Dice formulas displayed in i18n keys:
- PA = 1d100 + 4×(In/10) − 20
- WK = 1d100 + 2×(Ko/10 + In/10) − 20
- LP = 1d3 + 7 + (Ko/10)
- AP = 3d6 + class modifier
- B = 1d6 + race modifier
Recalculate button: Calls backend to recalculate from attributes.
Individual roll buttons per derived value with tooltip showing formula.
CharacterCreation/CharacterSkills.vue
Purpose: Step 4 — point-buy skill selection with category budgets.
Props: sessionData, skillCategories
Each category has a point budget (max_points). Skills have costs. Emits next, previous, save.
CharacterCreation/CharacterSpells.vue
Purpose: Step 5 — optional spell selection before finalization.
Props: sessionData, skillCategories
Emits: previous, finalize(finalData), save
Finalize calls POST /api/characters/create with complete session data. On success navigates to the new character's detail view.
AusruestungList.vue (legacy)
Purpose: Simple standalone equipment list (old design). Loaded via /ausruestung/:characterId route.
API: GET /ausruestung/:characterId (note: no /api prefix — likely a legacy endpoint).
Sub-component: AusruestungForm
AusruestungForm.vue (legacy)
Simple form to add equipment name/quantity/weight. Posts to /ausruestung (no /api prefix).
LanguageSwitcher.vue
Marked as "not used anymore" in a comment in the template. A simple <select> that calls languageStore.setLanguage(). Language switching is now done via UserProfileView.
HelloWorld.vue, TheWelcome.vue, WelcomeItem.vue
Vite/Vue scaffolding boilerplate — not used in the application.
7. API Layer
src/utils/api.js
import axios from 'axios'
const API = axios.create({
baseURL: import.meta.env.VITE_API_URL || 'https://bamort-api.trokan.de'
})
// Request interceptor
API.interceptors.request.use(config => {
const token = localStorage.getItem('token')
if (token) config.headers.Authorization = `Bearer ${token}`
return config
})
// Response interceptor
API.interceptors.response.use(
response => response,
error => {
if (error.response?.status === 401) {
localStorage.removeItem('token')
// redirect to /login is commented out
}
return Promise.reject(error)
}
)
export default API
Key facts:
- Default base URL:
https://bamort-api.trokan.de(production endpoint). In dev containers the env var overrides this tohttp://localhost:8180. - JWT is stored in and retrieved from
localStorage.getItem('token'). - 401 handler removes the token but does not automatically redirect (commented out). Components handle 401 errors individually.
API.defaults.baseURLis used inExportDialogto construct the PDF file download URL.
src/utils/auth.js
export function isLoggedIn() { return !!localStorage.getItem("token") }
export function logout() { localStorage.removeItem("token") }
Simple token-presence check. No JWT expiry validation, no PKCE, no refresh token mechanism.
All API endpoints used by the frontend
| Method | Endpoint | Used by |
|---|---|---|
POST |
/login |
LoginForm |
POST |
/register |
RegisterForm |
POST |
/password-reset/request |
ForgotPasswordForm |
GET |
/api/public/version |
LandingView |
GET |
/api/public/systeminfo |
SystemInfoView |
GET |
/api/user/profile |
userStore, UserProfileView |
PUT |
/api/user/display-name |
UserProfileView |
PUT |
/api/user/language |
UserProfileView |
PUT |
/api/user/email |
UserProfileView |
PUT |
/api/user/password |
UserProfileView |
GET |
/api/users |
UserManagementView |
PUT |
/api/users/:id/role |
UserManagementView |
DELETE |
/api/users/:id |
UserManagementView |
PUT |
/api/users/:id/password |
UserManagementView |
GET |
/api/characters |
CharacterList |
GET |
/api/characters/:id |
CharacterDetails |
PUT |
/api/characters/:id |
DatasheetView |
DELETE |
/api/characters/:id |
DeleteCharView |
GET |
/api/characters/:id/datasheet-options |
DatasheetView |
GET |
/api/characters/:id/image |
DatasheetView (imageSrc) |
POST |
/api/characters/:id/image |
ImageUploadCropper |
GET |
/api/characters/:id/audit-log |
AuditLogView |
GET |
/api/characters/:id/available-users |
VisibilityDialog |
POST |
/api/characters/create-session |
CharacterList |
GET |
/api/characters/create-sessions |
CharacterList |
GET |
/api/characters/create-session/:id |
CharacterCreation |
DELETE |
/api/characters/create-session/:id |
CharacterList, CharacterCreation |
GET |
/api/characters/skill-categories |
CharacterCreation |
POST |
/api/characters/improve-skill |
SkillView |
POST |
/api/characters/practice-points |
SkillView |
GET |
/api/pdf/templates |
ExportDialog |
GET |
/api/pdf/export/:id |
ExportDialog |
GET |
/api/pdf/file/:filename |
ExportDialog |
GET |
/api/importer/export/vtt/:id/file |
ExportDialog |
POST |
/api/importer/upload |
FileUploadPage |
GET |
/api/transfer/download/:id |
ExportDialog |
GET |
/api/equipment (maintenance) |
EquipmentView |
POST |
/api/equipment |
EquipmentView, WeaponView |
DELETE |
/api/equipment/:id |
EquipmentView, WeaponView |
PUT |
/api/equipment/:id |
WeaponView |
GET |
/api/maintenance |
Maintenance |
POST/PUT/DELETE |
/api/maintenance/skills |
maintenance/SkillView |
8. i18n
Configuration
- Library:
vue-i18nv11 (Composition API mode:legacy: false) - Default locale:
de(German), persisted inlocalStorage.language - Fallback locale:
en - Instance created in
languageStore.jsand exported asi18n
Languages
Two supported languages: German (de) and English (en).
Both locales are JavaScript objects (not JSON), exported as ES module defaults from single files:
src/locales/de(no.jsextension)src/locales/en(no.jsextension)
Translation Key Structure
| Top-level key | Domain |
|---|---|
common |
Shared labels: loading, cancel, save, edit, back, next |
auth |
Login/Registration form labels and messages |
forgotPassword |
Password reset flow |
import |
File import page |
menu |
Navigation menu items |
landing |
Landing page content |
equipment |
Equipment table, dialogs, messages |
skill |
Skill table headers and field labels |
spell |
Spell table headers and field labels |
weapon |
Weapon table, add/edit dialogs |
weaponskill |
Weapon skill table |
spells.learn |
Spell learning dialog |
stats |
Abbreviated stat names (St, Gs, Gw, Ko, In, Zt, Au, pA, Wk...) |
chars / singular view names |
View tab labels |
characters.list |
Dashboard character list UI |
characters.create |
Character wizard UI |
characters.basicInfo |
Wizard step 1: basic info |
characters.attributes |
Wizard step 2: attributes |
characters.derivedValues |
Wizard step 3: derived values |
characters.datasheet |
Datasheet edit hint |
experience |
EP + wealth section |
audit |
Audit log labels and filters |
export |
Export dialog labels and messages |
visibility |
Visibility/sharing dialog |
userManagement |
Admin user management |
profile |
User profile page |
deleteChar |
Delete character authorization message |
maintenance |
Maintenance section label |
maintmenu |
Maintenance sub-menu items |
believe |
Belief master data management |
gamesystem |
Game system master data |
litsource |
Literature source master data |
misc |
Misc lookup master data |
skillimprovement |
Skill improvement cost master data |
help |
Help page content + FAQ keys |
sponsors |
Sponsors page content |
systemInfo |
System info page content |
Note: Some keys exist only partially in EN (e.g., spells.learn labels use German text in the EN locale — incomplete translation).
Language switching
- Via
UserProfileView→PUT /api/user/language→ updatesthis.$i18n.localeandlocalStorage - Via
userStore.fetchCurrentUser()→ automatically appliesprofile.preferred_languageif set - The
LanguageSwitcher.vuecomponent exists but is marked unused.
9. Styling
Architecture
Global-first approach: All shared styles live in src/assets/main.css (imported in main.js as a side-effect import). Components use <style> (unscoped, contributing to global scope) or <style scoped> only for component-specific overrides.
Convention from vue.instructions.md: <template>, then <style scoped>, then <script> (template-first order, opposite of Vite default).
CSS Files
src/assets/base.css — CSS custom properties (variables):
:root {
--vt-c-black-soft: #222222; /* nav background */
--color-primary: #007bff; /* primary blue */
--color-bg-secondary: #f8f9fa;
--color-text-primary: #333;
--color-text-secondary: #495057;
--padding-xs/sm/md/lg /* 4/8/16/24px */
--margin-xs/sm/md/lg /* 4/8/16/24px */
--border-radius: 6px;
--box-shadow-light: 0 2px 4px rgba(0,0,0,0.1);
}
Dark mode support via @media (prefers-color-scheme: dark).
src/assets/main.css — Application shell and shared component styles:
Key CSS classes defined globally:
.top-nav— fixed 60px navigation bar (black background).main-content— flex-grow content area with top+bottom padding for fixed nav+submenu.fullwidth-container— full-width component wrapper with bottom padding for submenu.card— white bordered box with hover animation.grid-container,.grid-2/3/4-columns— responsive CSS grid layouts.page-header— section header with blue 2px bottom border.section-header— subsection header.submenu— fixed bottom tab bar for character/maintenance view switching.cd-table,.cd-table-header— data tables (green headers#1da766).list-item,.list-container,.charlist— character list cards.modal-overlay,.modal-content,.modal-header/body/footer— modal dialogs.form-group,.form-control,.form-row— form layout.btn,.btn-primary,.btn-secondary,.btn-danger,.btn-success— button variants.badge,.badge-success,.badge-danger,.badge-warning,.badge-secondary— status pills.loading,.empty-state— feedback states
Accent colour: #1da766 (green) used for table headers, learning mode buttons, active nav items.
Primary colour: #007bff (blue) used for page headers, primary buttons, links.
Component-specific styles
Most components either have no <style> block or minimal overrides. Examples:
SkillView.vue—@keyframes slideInfor learning mode animationEquipmentView.vue—.modal-fullscreenflex layout fixForgotPasswordForm.vue— local.reset-*classes for that form layout
10. Business Logic in Frontend
Form Validation
| Location | Validation |
|---|---|
RegisterForm |
Password == confirmPassword (client), email type input |
UserProfileView |
Display name max 30 chars, email != current, passwords min 6 chars + match |
UserManagementView |
Passwords min 6 chars + match |
CharacterBasicInfo |
All required fields non-empty (isValid computed) |
CharacterAttributes |
Race-specific beauty restrictions (Elves ≥81, Gnomes/Dwarves ≤80) |
FileUploadPage |
MIME type check for .json/.csv files |
SkillImproveDialog |
Must select at least one level, must be able to afford it |
SkillLearnDialog |
Skills must be affordable (EP + Gold check) |
Data Transformation
Character stat access (DatasheetView.getStat):
getStat(path) {
return path.split('.').reduce((obj, key) => obj?.[key], this.character) ?? '-'
}
Deep path traversal on nested character object. Same pattern for saving via pathParts loop.
Glaube (beliefs) encoding: Multi-select array ↔ comma-separated string conversion in DatasheetView.
Currency conversion (ExperianceView):
totalWealthInGS() {
return goldstücke + Math.floor(silberstücke / 10) + Math.floor(kupferstücke / 10)
}
Midgard RPG conversion: 1 GS = 10 SS = 10 KS.
Skill level improvement cost computation: Delegated to backend via API — frontend only displays what the server returns.
Character creation dice rolls:
rollDie(100)for attributes- Social class:
$rollDice(1, 100)+ class modifier → lookup table for result - Derived values: formulas like
1d100 + 4×(In/10) - 20for PA;1d3 + 7 + Ko/10for LP
Computed State
| Component | Computed | Logic |
|---|---|---|
LandingView |
isBackendAvailable |
backend version not in error states |
ExportDialog |
canExport |
format selected AND (if PDF) template selected |
VisibilityDialog |
filteredAvailableUsers |
exclude already-shared users, apply search query |
ExperianceView |
totalWealthInGS |
currency conversion formula |
CharacterDetails |
isOwner |
character.user_id === userStore.currentUser.id |
SkillImproveDialog |
remainingEP, remainingGold, remainingPP |
current resources minus selected cost |
SkillLearnDialog |
sortedFilteredSkills |
filter by category + search, sort by name/cost |
CharacterBasicInfo |
isValid |
all required fields filled |
userStore |
isAdmin, isMaintainer |
role string comparisons |
Auth State Management
Auth state is managed entirely via localStorage.token. No reactive store for the token itself — components call isLoggedIn() directly at mount time. The App.vue listens to storage events (cross-tab) and a custom auth-changed event (same-tab) to update the loggedIn data property and show/hide the navigation menu.
11. User Flows
Login Flow
/ (Landing) → click "Zum Login"
→ /login → LoginForm
→ POST /login { username, password }
→ JWT stored in localStorage.token
→ userStore.fetchCurrentUser() (GET /api/user/profile)
→ window.dispatchEvent('auth-changed')
→ App.vue re-checks auth, shows Menu
→ router.push('/dashboard')
→ /dashboard → DashboardView → CharacterList
Registration Flow
/register → RegisterForm
→ validates passwords match
→ POST /register { username, password, email }
→ shows success badge
→ user manually navigates to /login
Password Reset Flow
/forgot-password → ForgotPasswordForm
→ POST /password-reset/request { email, redirect_url }
→ shows success state (no redirect)
→ user receives email with link
→ link leads to /reset-password?token=X
→ ResetPasswordForm → POST /password-reset/confirm
Character List / Dashboard Flow
/dashboard → CharacterList
→ GET /api/characters → { self_owned, others }
→ GET /api/characters/create-sessions → { sessions }
→ renders owned + shared character links
→ renders active creation session cards
Character Creation Flow (New Character)
Dashboard → "Create New Character"
→ POST /api/characters/create-session → { session_id }
→ router.push('/character/create/:sessionId')
/character/create/:sessionId → CharacterCreation
→ GET /api/characters/create-session/:id (load state)
→ GET /api/characters/skill-categories (load skill budgets)
Step 1: CharacterBasicInfo
→ fill name, origin, belief, gender, race, class, social class
→ "Next: Attributes →"
→ PUT /api/characters/create-session/:id/step1 (save)
Step 2: CharacterAttributes
→ set 7 attributes (1–100), optionally roll dice
→ "Next: Derived Values →"
→ PUT /api/characters/create-session/:id/step2
Step 3: CharacterDerivedValues
→ view/adjust calculated values, optionally recalculate
→ "Next: Skills →"
→ PUT /api/characters/create-session/:id/step3
Step 4: CharacterSkills
→ select skills from category-budgeted list
→ "Next: Spells →"
→ PUT /api/characters/create-session/:id/step4
Step 5: CharacterSpells
→ optionally select spells
→ "Create Character" → POST /api/characters/create
→ router.push('/character/:newId')
Sessions can be resumed from the dashboard ("Continue Character Creation") or deleted.
Character Management Flow
/character/:id → CharacterDetails
→ GET /api/characters/:id
Tab: DatasheetView (default)
→ displays stats + bio info
→ double-click any field to edit inline
→ blur/Enter → PUT /api/characters/:id
Tab: SkillView
→ displays categorized skills + weapon skills + innate skills
→ owner: PP adjustment (+/− buttons)
→ owner: learning mode toggle (🎓)
→ SkillLearnDialog: learn new skill
→ SkillImproveDialog: improve existing skill
→ inline add skill dialog
Tab: WeaponView
→ displays weapons table
→ owner: add weapon (search + select from master data)
→ owner: edit weapon (magical, bonuses, value)
→ owner: delete weapon
Tab: SpellView
→ displays spells table
→ owner: 🎓 → SpellLearnDialog
Tab: EquipmentView
→ displays equipment table
→ owner: add equipment (search + select from master data)
→ owner: delete equipment
Tab: ExperianceView
→ EP display + add/remove
→ Gold display + add/remove
→ AuditLogView (filterable change history)
Tab: DeleteCharView
→ owner only: confirm + DELETE /api/characters/:id → /dashboard
PDF Export Flow
CharacterDetails → 📄 button → ExportDialog
Step 1: Load templates (GET /api/pdf/templates)
Step 2: Select format (PDF / VTT / BaMoRT JSON)
Step 3: If PDF → select template + optional showUserName checkbox
Step 4: Click "Export"
PDF path:
GET /api/pdf/export/:id?template=X → { filename }
window.open(API.baseURL + /api/pdf/file/filename, '_blank')
VTT path:
GET /api/importer/export/vtt/:id/file (blob)
→ programmatic <a download> click
BaMoRT JSON path:
GET /api/transfer/download/:id (blob)
→ programmatic <a download> click
Visibility / Sharing Flow
CharacterDetails → 🌐/🔒 button (owner only) → VisibilityDialog
→ GET /api/characters/:id/available-users
→ GET /api/characters/:id/shares (current)
→ Toggle private/public radio
→ Search + add users from available list
→ Remove users from shared list
→ "Save" → PUT /api/characters/:id/visibility + share update
→ emits visibility-updated → CharacterDetails updates character.public
Admin User Management Flow
Menu Admin → "User Management" (admin only)
→ /users → UserManagementView
→ GET /api/users
→ table of all users
Per user (excluding self):
"Change Role" → modal → select role → PUT /api/users/:id/role
"Change Password" → modal → new password → PUT /api/users/:id/password
"Delete" → confirm modal → DELETE /api/users/:id
12. Component Hierarchy
App.vue
├── Menu.vue [if logged in]
└── <router-view> (main content)
├── LandingView.vue [/]
├── LoginView.vue [/login]
│ └── LoginForm.vue
├── RegisterView.vue [/register]
│ └── RegisterForm.vue
├── ForgotPasswordView.vue [/forgot-password]
│ └── ForgotPasswordForm.vue
├── ResetPasswordView.vue [/reset-password]
│ └── ResetPasswordForm.vue
├── DashboardView.vue [/dashboard] *auth*
│ └── CharacterList.vue
│ └── CharacterCreationSessions.vue
├── UserProfileView.vue [/profile] *auth*
├── UserManagementView.vue [/users] *admin*
├── FileUploadPage.vue [/upload] *auth*
├── MaintenanceView.vue [/maintenance] *auth*
│ └── Maintenance.vue
│ ├── maintenance/SkillView.vue (default)
│ ├── maintenance/SpellView.vue
│ ├── maintenance/EquipmentView.vue
│ ├── maintenance/WeaponView.vue
│ ├── maintenance/WeaponSkillView.vue
│ ├── maintenance/BelieveView.vue
│ ├── maintenance/GameSystemView.vue
│ ├── maintenance/LitSourceView.vue
│ ├── maintenance/MiscLookupView.vue
│ └── maintenance/SkillImprovementCostView.vue
├── AusruestungView.vue [/ausruestung/:characterId] *auth*
│ └── AusruestungList.vue
│ └── AusruestungForm.vue
├── SponsorsView.vue [/sponsors]
├── HelpView.vue [/help]
├── SystemInfoView.vue [/system-info]
├── CharacterDetails.vue [/character/:id] *auth*
│ ├── ExportDialog.vue
│ ├── VisibilityDialog.vue
│ └── <dynamic component :is="currentView">
│ ├── DatasheetView.vue (default)
│ │ └── ImageUploadCropper.vue
│ ├── SkillView.vue
│ │ ├── SkillLearnDialog.vue
│ │ └── SkillImproveDialog.vue
│ ├── WeaponView.vue
│ ├── SpellView.vue
│ │ └── SpellLearnDialog.vue
│ ├── EquipmentView.vue
│ ├── ExperianceView.vue
│ │ └── AuditLogView.vue
│ └── DeleteCharView.vue
└── CharacterCreation.vue [/character/create/:sessionId] *auth*
├── CharacterCreation/CharacterBasicInfo.vue (step 1)
├── CharacterCreation/CharacterAttributes.vue (step 2)
├── CharacterCreation/CharacterDerivedValues.vue (step 3)
├── CharacterCreation/CharacterSkills.vue (step 4)
└── CharacterCreation/CharacterSpells.vue (step 5)
Component-to-Parent Emits Summary
| Component | Emits | Consumed by |
|---|---|---|
CharacterCreationSessions |
continue-session, delete-session |
CharacterList |
ExportDialog |
update:showDialog, export-success |
CharacterDetails |
VisibilityDialog |
update:showDialog, visibility-updated |
CharacterDetails |
DatasheetView |
character-updated |
CharacterDetails |
SkillView |
character-updated |
CharacterDetails |
WeaponView |
character-updated |
CharacterDetails |
EquipmentView |
character-updated |
CharacterDetails |
SpellView |
character-updated |
CharacterDetails |
SkillLearnDialog |
close, skill-learned |
SkillView |
SkillImproveDialog |
close, skill-updated |
SkillView |
SpellLearnDialog |
close, spell-learned |
SpellView |
ImageUploadCropper |
image-updated |
DatasheetView |
DeleteCharView |
deleted, cancel |
CharacterDetails |
AusruestungForm |
added |
AusruestungList |
CharacterBasicInfo |
next, save |
CharacterCreation |
CharacterAttributes |
next, previous, save |
CharacterCreation |
CharacterDerivedValues |
next, previous, save |
CharacterCreation |
CharacterSkills |
next, previous, save |
CharacterCreation |
CharacterSpells |
previous, finalize, save |
CharacterCreation |