diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 1324a02..02f1f2a 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,14 +1,175 @@ -# general instructions -- You are GitHub Copilot, an AI pair programmer. -- You are designed to help write code faster and with less effort. -- You can generate code snippets, complete code, and suggest improvements. -- You can understand and write code in multiple programming languages. -- You can help with debugging and fixing errors in code. -- You can assist with writing tests and documentation. -- You can learn from the context of the code you are working on. -- You will NOT write example or demonstration code. -- The GitHub repository is github.com:Bardioc26/bamort.git. -- The main package is cmd/backend/main.go. -- The main function is in cmd/backend/main.go. -- You write tests ONLY in _test.go files. -- You will NEVER create files for testing with a main() function! +# Bamort Development Instructions + +Bamort is a role-playing game character management system (MOAM replacement) with a Go backend and Vue.js frontend. + +## Project Overview + +- **Repository**: github.com:Bardioc26/bamort.git +- **Architecture**: Monorepo with separate backend/frontend in Docker containers +- **Backend**: Go 1.25 + Gin framework + GORM + MariaDB +- **Frontend**: Vue 3 + Vite + i18n +- **Development**: Docker Compose with live-reload (Air for Go, Vite HMR for Vue) + +## Backend Architecture (`backend/`) + +### Module Structure +Each domain module follows this pattern (e.g., `character/`, `pdfrender/`, `equipment/`): +``` +module/ + handlers.go # HTTP handlers (Gin controllers) + routes.go # Route registration: RegisterRoutes(r *gin.RouterGroup) + *_test.go # Tests with setupTestEnvironment(t) +``` + +**Key conventions:** +- **Entry point**: `cmd/main.go` registers all modules via `RegisterRoutes(protected)` +- **Models**: `models/` contains GORM entities (e.g., `Char`, `SkFertigkeit`, `EqWaffe`) +- **Database**: Shared `database.DB` instance, use `models.MigrateStructure(db)` for migrations +- **Configuration**: `config.Cfg` loaded from env vars (see `config/config.go`) + - `TEMPLATES_DIR` for PDF templates (default: `./templates`) + - `ENVIRONMENT=test|development|production` + - `DATABASE_TYPE=mysql|sqlite` + +### Testing Requirements +- **NEVER** create test files with `main()` functions +- **ALWAYS** use `_test.go` suffix +- **ALWAYS** call `setupTestEnvironment(t)` at start of each test: + ```go + func setupTestEnvironment(t *testing.T) { + original := os.Getenv("ENVIRONMENT") + os.Setenv("ENVIRONMENT", "test") + t.Cleanup(func() { /* restore */ }) + } + ``` +- Use `testutils.SetupTestDB()` for database tests (creates SQLite test DB) +- Test character ID 18 (Fanjo Vetrani) exists in test database + +### API Patterns +- Protected routes under `/api` prefix require JWT authentication +- Use `respondWithError(c, status, message)` for error responses +- Route registration example: + ```go + func RegisterRoutes(r *gin.RouterGroup) { + group := r.Group("/module") + group.GET("/list", ListHandler) + group.POST("/create", CreateHandler) + } + ``` + +## Frontend Architecture (`frontend/`) + +### Component Structure +- **Views**: `src/views/` - Page-level components (CharacterView, DashboardView) +- **Components**: `src/components/` - Reusable components (CharacterDetails, SkillView) +- **Utils**: `src/utils/api.js` - Axios instance with JWT interceptor +- **Locales**: `src/locales/de` and `src/locales/en` - i18n translations (JS objects, not JSON) +- **Store**: Pinia stores in `src/stores/` + +### API Communication +- Use `API.get()`, `API.post()` from `utils/api.js` (auto-adds auth headers) +- Base URL: `import.meta.env.VITE_API_URL` (defaults to `http://localhost:8180`) +- Example: + ```js + const response = await API.get(`/api/characters/${this.id}`) + ``` + +### Translation Pattern +Add to both `locales/de` and `locales/en`: +```js +export default { + export: { + selectTemplate: 'Vorlage wählen', // DE + exportPDF: 'PDF Export' + } +} +``` + +## Docker Development Workflow + +### Verify Containers +```bash +docker ps | grep bamort-backend-dev # Must be running before testing +``` + +### Container Names & Ports +- `bamort-backend-dev`: Go API at http://localhost:8180 (Air live-reload) +- `bamort-frontend-dev`: Vue at http://localhost:5173 (Vite HMR) +- `bamort-mariadb-dev`: MariaDB at localhost:3306 +- `bamort-phpmyadmin-dev`: http://localhost:8082 + +### Rebuild After Dockerfile Changes +```bash +cd /data/dev/bamort +docker-compose -f docker/docker-compose.dev.yml build bamort-backend-dev +docker-compose -f docker/docker-compose.dev.yml up -d bamort-backend-dev +``` + +### View Logs +```bash +docker logs bamort-backend-dev --tail=50 +docker logs bamort-frontend-dev --tail=20 +``` + +## Testing Commands + +### Backend Tests +```bash +cd /data/dev/bamort/backend +go test -v ./pdfrender/ -run TestExportCharacterToPDF +go test -v ./character/ +``` + +### Frontend +- HMR auto-reloads on file save +- Check browser console and `docker logs bamort-frontend-dev` + +## PDF Rendering Module (`pdfrender/`) + +- Uses `chromedp` for HTML→PDF (requires Chromium in Docker) +- Templates in `templates/Default_A4_Quer/` (page1_stats.html, page2_play.html, etc.) +- Continuation pages auto-generated for overflow (page1.2_stats.html pattern) +- Test with character ID 18: generates 4 pages + continuations if needed +- Template capacity defined in HTML comments: `` + +## Common Patterns + +### Error Handling (Backend) +```go +if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return +} +``` + +### Modal Dialogs (Frontend) +```vue +
+``` + +### Popup Blocker Workaround +Open window **synchronously** before async calls: +```js +const newWindow = window.open('', '_blank') +// ... then await API call ... +newWindow.location.href = url +``` + +## Critical Rules + +1. **NEVER** write example/demo code - only production code +2. **NEVER** create test files with `main()` - use `_test.go` +3. **ALWAYS** check `docker ps` before assuming containers are running +4. **ALWAYS** use TDD: write failing test first, then implement +5. **ALWAYS** use KISS principle: simplest solution that works +6. **ALWAYS** add translations to both DE and EN locales +7. **ALWAYS** use global CSS definition to ensure consistent style + +## File-Specific Instructions + +Load additional instructions for specific file types: +- Go files: See `.github/instructions/go.instructions.md` +- Vue files: See `.github/instructions/vue.instructions.md` +- js files: See `.github/instructions/js.instructions.md` +- css files: See `.github/instructions/css.instructions.md` diff --git a/.github/instructions/css.instructions.md b/.github/instructions/css.instructions.md new file mode 100644 index 0000000..b5bfe87 --- /dev/null +++ b/.github/instructions/css.instructions.md @@ -0,0 +1,484 @@ +--- +description: 'Instructions for writing CSS following project conventions and best practices' +applyTo: '**/*.css,**/*.vue' +--- + +# CSS Development Instructions + +Follow project-specific CSS conventions and modern best practices. + +## Scoped Styles in Vue Components + +### Always Use Scoped Styles +```vue + +``` + +**Critical**: Use `scoped` attribute to prevent style conflicts between components. + +## Layout Patterns + +### Flexbox for Component Layouts +Standard pattern for headers, modals, and lists: + +```css +.header-content { + display: flex; + align-items: center; + gap: 15px; /* Use gap instead of margin */ +} + +.modal-footer { + display: flex; + justify-content: flex-end; + gap: 10px; +} +``` + +### Common Flex Patterns +```css +/* Horizontal layout with spacing */ +.horizontal-layout { + display: flex; + gap: 10px; + align-items: center; +} + +/* Vertical centering */ +.centered { + display: flex; + align-items: center; + justify-content: center; + height: 100vh; +} + +/* Space between items */ +.space-between { + display: flex; + justify-content: space-between; + align-items: center; +} +``` + +## Modal Dialog Styling + +### Standard Modal Pattern +```css +/* Overlay - covers entire viewport */ +.modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +/* Modal content container */ +.modal-content { + background: white; + border-radius: 8px; + width: 90%; + max-width: 500px; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); +} + +/* Modal sections */ +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 20px; + border-bottom: 1px solid #dee2e6; +} + +.modal-body { + padding: 20px; + position: relative; /* For loading overlays */ +} + +.modal-footer { + display: flex; + justify-content: flex-end; + gap: 10px; + padding: 20px; + border-top: 1px solid #dee2e6; +} +``` + +## Button Styling + +### Standard Button Styles +```css +/* Primary action button */ +.btn-primary, +.btn-export { + padding: 10px 20px; + border: 1px solid #007bff; + border-radius: 6px; + background: #007bff; + color: white; + cursor: pointer; + font-weight: 500; + transition: all 0.2s ease; +} + +.btn-primary:hover:not(:disabled) { + background: #0056b3; + border-color: #0056b3; +} + +.btn-primary:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +/* Cancel/secondary button */ +.btn-cancel { + padding: 10px 20px; + border: 1px solid #dee2e6; + border-radius: 6px; + background: #f8f9fa; + color: #495057; + cursor: pointer; + font-weight: 500; + transition: all 0.2s ease; +} + +.btn-cancel:hover { + background: #e9ecef; + border-color: #adb5bd; +} + +/* Icon-only button */ +.export-button-small { + width: 40px; + height: 40px; + padding: 0; + border: 1px solid #007bff; + border-radius: 8px; + background: #007bff; + color: white; + font-size: 1.2rem; + cursor: pointer; + transition: all 0.2s ease; + flex-shrink: 0; +} + +.export-button-small:hover { + background: #0056b3; + transform: scale(1.05); +} +``` + +## Form Elements + +### Input Styling +```css +.template-select, +input[type="text"], +input[type="email"] { + width: 100%; + padding: 10px 12px; + border: 1px solid #dee2e6; + border-radius: 6px; + background: white; + color: #495057; + font-size: 0.95rem; +} + +.template-select:focus, +input:focus { + outline: none; + border-color: #007bff; + box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25); +} + +input:disabled, +select:disabled { + opacity: 0.6; + cursor: not-allowed; + background: #e9ecef; +} +``` + +### Checkbox Styling +```css +.checkbox-label { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + user-select: none; +} + +.checkbox-label input[type="checkbox"] { + width: 18px; + height: 18px; + cursor: pointer; +} +``` + +## Loading Animations + +### Spinner Animation +```css +.spinner { + border: 4px solid #f3f3f3; + border-top: 4px solid #007bff; + border-radius: 50%; + width: 50px; + height: 50px; + animation: spin 1s linear infinite; + margin-bottom: 15px; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} +``` + +### Loading Overlay +```css +.loading-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(255, 255, 255, 0.95); + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + z-index: 10; +} + +.loading-overlay p { + color: #007bff; + font-weight: 500; + margin: 0; +} +``` + +## Color Scheme + +### Standard Colors +```css +/* Primary */ +--primary: #007bff; +--primary-hover: #0056b3; + +/* Text */ +--text-primary: #333; +--text-secondary: #495057; +--text-muted: #666; + +/* Backgrounds */ +--bg-light: #f8f9fa; +--bg-gray: #e9ecef; + +/* Borders */ +--border-light: #dee2e6; +--border-dark: #adb5bd; + +/* Semantic colors */ +--success: #28a745; +--danger: #dc3545; +--warning: #ffc107; +``` + +Use these consistently across components for visual coherence. + +## Spacing System + +### Use Consistent Spacing +```css +/* Prefer these spacing values */ +gap: 8px; /* Tight spacing */ +gap: 10px; /* Default spacing */ +gap: 15px; /* Medium spacing */ +gap: 20px; /* Large spacing */ + +padding: 10px 12px; /* Inputs */ +padding: 20px; /* Modal sections */ +``` + +## Typography + +### Font Sizing +```css +h2 { + font-size: 1.5rem; + margin: 0; + color: #333; +} + +h3 { + font-size: 1.25rem; + margin: 0; + color: #333; +} + +p, span { + font-size: 0.95rem; + color: #495057; +} + +small { + font-size: 0.875rem; + color: #666; +} +``` + +### Font Weight +```css +font-weight: 400; /* Normal */ +font-weight: 500; /* Medium (buttons, labels) */ +font-weight: 600; /* Semibold (headings) */ +``` + +## Transitions and Animations + +### Standard Transitions +```css +/* Buttons, interactive elements */ +transition: all 0.2s ease; + +/* Background changes */ +transition: background 0.3s ease; + +/* Transform animations */ +transition: transform 0.2s ease; +``` + +### Hover Effects +```css +button:hover { + transform: scale(1.02); /* Subtle scale */ +} + +.card:hover { + box-shadow: 0 6px 12px rgba(0, 0, 0, 0.15); +} +``` + +## Responsive Design + +### Mobile-First Approach +```css +/* Base styles (mobile) */ +.modal-content { + width: 90%; + max-width: 500px; +} + +/* Tablet and up */ +@media (min-width: 768px) { + .modal-content { + width: 600px; + } +} + +/* Desktop */ +@media (min-width: 1024px) { + .modal-content { + width: 700px; + } +} +``` + +## Best Practices + +1. **Always use `scoped`** on Vue component styles +2. **Use flexbox for layouts** instead of floats or positioning +3. **Use `gap` property** instead of margins for spacing +4. **Keep selectors simple** - avoid deep nesting +5. **Use relative units** (`rem`, `em`) for font sizes +6. **Add transitions** for interactive elements +7. **Use CSS variables** for repeated values +8. **Keep z-index organized** (modals: 1000, dropdowns: 100, etc.) +9. **Test hover states** for all interactive elements +10. **Include disabled states** for buttons and inputs + +## Anti-Patterns to Avoid + +❌ Don't use `!important` unless absolutely necessary +❌ Don't use inline styles in templates - use classes +❌ Don't use fixed pixel widths for responsive layouts +❌ Don't nest selectors more than 3 levels deep +❌ Don't use IDs for styling - use classes +❌ Don't forget `:hover`, `:focus`, `:disabled` states +❌ Don't use `position: absolute` unless necessary +❌ Don't forget to test in different viewport sizes +❌ Don't use vendor prefixes manually - use autoprefixer + +## Common Component Patterns + +### Close Button +```css +.close-button { + background: none; + border: none; + font-size: 1.5rem; + color: #999; + cursor: pointer; + padding: 0; + width: 30px; + height: 30px; + display: flex; + align-items: center; + justify-content: center; +} + +.close-button:hover { + color: #333; +} +``` + +### Full-Height Container +```css +.character-details { + width: 100%; + height: 100%; + padding: 20px; + box-sizing: border-box; + display: flex; + flex-direction: column; +} +``` + +### Submenu/Tabs +```css +.submenu { + display: flex; + gap: 10px; + margin: 20px 0; + flex-wrap: wrap; +} + +.submenu button { + padding: 10px 16px; + border: 1px solid #dee2e6; + border-radius: 6px; + background: #f8f9fa; + color: #495057; + cursor: pointer; + transition: all 0.2s ease; +} + +.submenu button.active { + background: #007bff; + color: white; + border-color: #007bff; +} +``` diff --git a/.github/instructions/js.instructions.md b/.github/instructions/js.instructions.md new file mode 100644 index 0000000..191d8f1 --- /dev/null +++ b/.github/instructions/js.instructions.md @@ -0,0 +1,342 @@ +--- +description: 'Instructions for writing JavaScript following project conventions and ES6+ best practices' +applyTo: '**/*.js,**/*.mjs' +--- + +# JavaScript Development Instructions + +Follow ES6+ best practices and project-specific patterns for JavaScript code. + +## Module System + +### ES6 Modules +Use ES6 import/export syntax: + +```js +// Named exports +export const API = axios.create({ ... }) +export function helper() { ... } + +// Default export +export default { + messages: { ... } +} + +// Imports +import API from '../utils/api' +import { createI18n } from 'vue-i18n' +``` + +## API Configuration (`utils/api.js`) + +### Standard Axios Instance Pattern +```js +import axios from 'axios' + +const API = axios.create({ + baseURL: import.meta.env.VITE_API_URL || 'http://localhost:8180' +}) + +// Request interceptor - adds auth token +API.interceptors.request.use( + (config) => { + const token = localStorage.getItem('token') + if (token) { + config.headers.Authorization = `Bearer ${token}` + } + return config + }, + (error) => Promise.reject(error) +) + +// Response interceptor - handles 401 +API.interceptors.response.use( + (response) => response, + (error) => { + if (error.response?.status === 401) { + localStorage.removeItem('token') + // Optional: redirect to login + } + return Promise.reject(error) + } +) + +export default API +``` + +**Key points:** +- Single Axios instance for the entire app +- Auto-adds Authorization header from localStorage +- Auto-handles 401 responses +- Uses Vite environment variables + +## Pinia Store Pattern (`stores/`) + +### Standard Store Structure +```js +import { defineStore } from 'pinia' +import { createI18n } from 'vue-i18n' +import de from '@/locales/de' +import en from '@/locales/en' + +export const i18n = createI18n({ + legacy: false, + locale: localStorage.getItem('language') || 'de', + fallbackLocale: 'en', + messages: { de, en } +}) + +export const useLanguageStore = defineStore('language', { + state: () => ({ + currentLanguage: localStorage.getItem('language') || 'de' + }), + actions: { + setLanguage(lang) { + this.currentLanguage = lang + i18n.global.locale.value = lang + localStorage.setItem('language', lang) + } + } +}) +``` + +## Locale Files (`locales/de`, `locales/en`) + +### Translation Object Structure +**Important**: Locale files use `.js` extension and export objects (not `.json`): + +```js +// locales/de +export default { + char: 'Figur', + menu: { + Datasheet: 'Datenblatt', + Skill: 'Fertigkeiten' + }, + export: { + selectTemplate: 'Vorlage wählen', + exportPDF: 'PDF Export', + exporting: 'Exportiere...', + pleaseSelectTemplate: 'Bitte Vorlage auswählen' + } +} +``` + +**Conventions:** +- Nested objects for grouping related translations +- camelCase for keys +- Always add to both `de` and `en` files simultaneously +- Keep structure identical between languages + +## Async/Await Patterns + +### Error Handling +```js +try { + const response = await API.get('/api/endpoint') + return response.data +} catch (error) { + console.error('Operation failed:', error) + throw error // or handle gracefully +} +``` + +### Multiple Parallel Requests +```js +const [characters, templates] = await Promise.all([ + API.get('/api/characters'), + API.get('/api/pdf/templates') +]) +``` + +## Browser APIs + +### LocalStorage Usage +```js +// Save +localStorage.setItem('token', response.data.token) +localStorage.setItem('language', 'de') + +// Retrieve +const token = localStorage.getItem('token') +const lang = localStorage.getItem('language') || 'de' + +// Remove +localStorage.removeItem('token') +``` + +### Blob/File Handling +```js +// Create blob from response +const blob = new Blob([response.data], { type: 'application/pdf' }) +const url = window.URL.createObjectURL(blob) + +// Open in new window +const pdfWindow = window.open(url, '_blank') + +// Clean up after use +setTimeout(() => window.URL.revokeObjectURL(url), 10000) +``` + +### URL Parameters +```js +// Build query string +const params = new URLSearchParams({ + template: templateId, + showUserName: 'true' +}) +const url = `/api/export?${params.toString()}` + +// Extract from params +const queryParams = Object.fromEntries(params) +``` + +## Event Handling + +### Debouncing/Throttling +For search inputs or resize events: + +```js +let debounceTimer +function debounce(func, delay = 300) { + return (...args) => { + clearTimeout(debounceTimer) + debounceTimer = setTimeout(() => func(...args), delay) + } +} + +// Usage +const search = debounce(async (query) => { + const results = await API.get(`/api/search?q=${query}`) +}, 300) +``` + +### Cleanup +```js +// Save timer ID for cleanup +this.timer = setTimeout(() => { ... }, 5000) + +// Clean up in component lifecycle +beforeUnmount() { + if (this.timer) clearTimeout(this.timer) +} +``` + +## Array/Object Operations + +### Array Methods +```js +// Filter +const learned = skills.filter(s => s.Fertigkeitswert > 0) + +// Map +const names = characters.map(c => c.name) + +// Find +const char = characters.find(c => c.id === 18) + +// Some/Every +const hasSkills = character.fertigkeiten.some(f => f.Fertigkeitswert > 0) +``` + +### Object Destructuring +```js +// Response destructuring +const { data, headers, status } = response + +// Props destructuring +const { character, template, showUserName = false } = options +``` + +### Spread Operator +```js +// Merge objects +const merged = { ...defaults, ...userOptions } + +// Copy array +const copy = [...originalArray] +``` + +## Common Patterns + +### Loading State Management +```js +export default { + data() { + return { + isLoading: false, + data: null, + error: null + } + }, + async created() { + await this.loadData() + }, + methods: { + async loadData() { + this.isLoading = true + this.error = null + try { + const response = await API.get('/api/data') + this.data = response.data + } catch (error) { + this.error = error.message + } finally { + this.isLoading = false + } + } + } +} +``` + +### Form Validation +```js +validateForm() { + if (!this.selectedTemplate) { + alert(this.$t('export.pleaseSelectTemplate')) + return false + } + return true +} + +async submit() { + if (!this.validateForm()) return + + // Proceed with submission +} +``` + +## Best Practices + +1. **Use `const` by default**, `let` when reassignment needed, never `var` +2. **Prefer arrow functions** for callbacks and short functions +3. **Use template literals** for string interpolation +4. **Handle promise rejections** with try/catch or .catch() +5. **Clean up timers and intervals** in component lifecycle +6. **Use optional chaining** `?.` for nested properties +7. **Use nullish coalescing** `??` instead of `||` for default values +8. **Keep functions small** and single-purpose +9. **Document complex logic** with comments +10. **Use meaningful variable names** - avoid single letters except loops + +## Anti-Patterns to Avoid + +❌ Don't use `var` - use `const` or `let` +❌ Don't ignore promise rejections +❌ Don't mutate function parameters +❌ Don't create memory leaks (clean up listeners, timers) +❌ Don't use `eval()` or `new Function()` +❌ Don't mix callbacks and promises +❌ Don't forget to handle edge cases (null, undefined, empty arrays) +❌ Don't use `==` - always use `===` for comparisons + +## Environment Variables (Vite) + +### Accessing Variables +```js +const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:8180' +const isDev = import.meta.env.DEV +const isProd = import.meta.env.PROD +``` + +**Convention**: Prefix all custom variables with `VITE_` diff --git a/.github/instructions/vue.instructions.md b/.github/instructions/vue.instructions.md new file mode 100644 index 0000000..7196368 --- /dev/null +++ b/.github/instructions/vue.instructions.md @@ -0,0 +1,307 @@ +--- +description: 'Instructions for writing Vue 3 components following project conventions and best practices' +applyTo: '**/*.vue' +--- + +# Vue 3 Development Instructions + +Follow Vue 3 best practices and project-specific conventions when writing components. + +## Component Structure + +### Standard Component Layout +```vue + + + + + + + +``` + +**Order matters**: Template, Style, Script (as seen throughout the codebase) + +### Options API Pattern (Primary) +Use Options API for consistency with existing codebase: + +```vue + +``` + +## API Communication + +### Using the API Utility +Always use `API` from `utils/api.js` - it handles authentication automatically: + +```js +import API from '../utils/api' + +// In methods: +const response = await API.get(`/api/characters/${this.id}`) +const data = await API.post('/api/characters', character) +``` + +**Never** manually add Authorization headers - the interceptor handles this. + +### Error Handling Pattern +```js +try { + const response = await API.get(`/api/endpoint`) + this.data = response.data +} catch (error) { + console.error('Failed to load data:', error) + alert(this.$t('errors.loadFailed') + ': ' + (error.response?.data?.error || error.message)) +} +``` + +## Internationalization (i18n) + +### Using Translations +```vue + +