Scaffold Vue 3 + TypeScript frontend (Phase F0)
Vue 3 with Composition API, Pinia, Vue Router, Tailwind v4: - Vite build with @/ path aliases and backend proxy - TypeScript strict mode with proper type imports - Pinia stores (auth, game) with setup syntax and persistence - Vue Router with auth guards and lazy-loaded routes - Tailwind v4 with custom theme (Pokemon types, dark UI) - Vitest configured for component testing - ESLint v9 flat config with Vue/TypeScript support Pages: Home, Login, Register, Campaign, Collection, DeckBuilder, Match Components: AppHeader with auth-aware navigation Types: Card, GameState, Player, Deck, Campaign types Also adds frontend-code-audit skill with patterns for: - Error handling (unhandled promises, empty catches) - Security (XSS, token storage, input validation) - Architecture (Composition API, Pinia patterns, Phaser rules) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
5b12df0cb1
commit
b9b803da66
153
.claude/skills/frontend-code-audit/SKILL.md
Normal file
153
.claude/skills/frontend-code-audit/SKILL.md
Normal file
@ -0,0 +1,153 @@
|
||||
# Frontend Code Audit Skill
|
||||
|
||||
Perform systematic code audits on Vue/TypeScript/Phaser frontend code to find anti-patterns, hidden errors, security issues, and architecture violations.
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
/frontend-code-audit [category] [path]
|
||||
```
|
||||
|
||||
**Categories:**
|
||||
- `errors` - Unhandled errors, silent failures, missing error states
|
||||
- `security` - XSS risks, data exposure, auth token handling
|
||||
- `architecture` - Vue/Pinia patterns, Phaser integration, component design
|
||||
- `all` - Run all audit categories (default)
|
||||
|
||||
**Path:** Optional path to audit (defaults to `frontend/src/`)
|
||||
|
||||
## Examples
|
||||
|
||||
```
|
||||
/frontend-code-audit # Full audit of frontend/src/
|
||||
/frontend-code-audit errors # Just error handling patterns
|
||||
/frontend-code-audit security stores/ # Security audit of stores/
|
||||
/frontend-code-audit architecture game/ # Architecture audit of Phaser code
|
||||
```
|
||||
|
||||
## Instructions
|
||||
|
||||
When this skill is invoked:
|
||||
|
||||
1. **Load Pattern Definitions**
|
||||
Read the YAML files in `patterns/` directory for the requested category.
|
||||
|
||||
2. **Search Codebase**
|
||||
For each pattern, use Grep with the specified regex to find matches.
|
||||
Focus on production code (skip test files unless pattern specifies otherwise).
|
||||
|
||||
3. **Analyze Context**
|
||||
For each match, read surrounding code to determine verdict:
|
||||
- **ISSUE**: Code should be changed (explain why)
|
||||
- **WARNING**: Potential problem, needs review
|
||||
- **OK**: Pattern detected but implementation is correct (explain why)
|
||||
|
||||
4. **Generate Report**
|
||||
Output structured report with:
|
||||
- Summary stats (issues/warnings/ok by category)
|
||||
- Detailed findings with file:line references
|
||||
- Recommendations for each issue
|
||||
|
||||
## Verdict Guidelines
|
||||
|
||||
### Error Handling
|
||||
|
||||
**ISSUE** when:
|
||||
- Promise rejection unhandled (no `.catch()` or try/catch)
|
||||
- Fetch error caught but UI shows no feedback
|
||||
- Error logged but component renders as if success
|
||||
- Empty catch block swallows error
|
||||
|
||||
**OK** when:
|
||||
- Error caught and user notified (toast, error state)
|
||||
- Error boundary catches and displays fallback
|
||||
- Retry logic with eventual user feedback
|
||||
- Graceful degradation documented
|
||||
|
||||
### Security
|
||||
|
||||
**ISSUE** when:
|
||||
- `v-html` used with any user-provided data
|
||||
- Auth token stored in localStorage without httpOnly consideration
|
||||
- Sensitive data (passwords, tokens) in console.log
|
||||
- Query params contain auth tokens
|
||||
|
||||
**OK** when:
|
||||
- `v-html` only used with sanitized/trusted content
|
||||
- Token storage follows project auth pattern
|
||||
- Logging excludes sensitive fields
|
||||
- Auth handled via httpOnly cookies or secure headers
|
||||
|
||||
### Architecture
|
||||
|
||||
**ISSUE** when:
|
||||
- Game logic (damage calc, turn state) in Phaser scene
|
||||
- API calls made directly in component (not via composable/store)
|
||||
- Phaser scene imports from stores without bridge pattern
|
||||
- Component has >200 lines (should split)
|
||||
|
||||
**OK** when:
|
||||
- Phaser only handles rendering/animation
|
||||
- API access through typed composables
|
||||
- Vue-Phaser communication via event bridge
|
||||
- Large component is page-level layout
|
||||
|
||||
## Pattern File Format
|
||||
|
||||
```yaml
|
||||
name: Category Name
|
||||
description: What this category audits
|
||||
|
||||
patterns:
|
||||
- id: unique-pattern-id
|
||||
description: Human-readable description
|
||||
grep_pattern: 'regex pattern for Grep tool'
|
||||
file_glob: '*.vue' # Optional, defaults to *.{vue,ts}
|
||||
exclude_tests: true # Optional, defaults to true
|
||||
multiline: false # Optional, for cross-line patterns
|
||||
context_lines: 3 # Lines to read around match
|
||||
verdict_hints:
|
||||
ISSUE_IF: "condition that makes this an issue"
|
||||
OK_IF: "condition that makes this acceptable"
|
||||
```
|
||||
|
||||
## Output Format
|
||||
|
||||
```markdown
|
||||
# Frontend Code Audit Report
|
||||
|
||||
**Scope:** frontend/src/
|
||||
**Categories:** errors, security, architecture
|
||||
**Date:** YYYY-MM-DD
|
||||
|
||||
## Summary
|
||||
|
||||
| Category | Issues | Warnings | OK |
|
||||
|----------|--------|----------|-----|
|
||||
| Errors | 2 | 1 | 5 |
|
||||
| Security | 0 | 2 | 3 |
|
||||
| Architecture | 1 | 0 | 4 |
|
||||
|
||||
## Issues (Fix Required)
|
||||
|
||||
### [ISSUE] security.v-html-user-data
|
||||
**File:** src/components/CardDescription.vue:42
|
||||
**Pattern:** v-html with potentially unsafe content
|
||||
|
||||
```vue
|
||||
<div v-html="card.description" />
|
||||
```
|
||||
|
||||
**Problem:** Card description from API could contain malicious scripts.
|
||||
**Recommendation:** Use text interpolation or sanitize with DOMPurify.
|
||||
|
||||
---
|
||||
|
||||
## Warnings (Review Needed)
|
||||
|
||||
...
|
||||
|
||||
## OK (Verified Correct)
|
||||
|
||||
...
|
||||
```
|
||||
204
.claude/skills/frontend-code-audit/patterns/architecture.yaml
Normal file
204
.claude/skills/frontend-code-audit/patterns/architecture.yaml
Normal file
@ -0,0 +1,204 @@
|
||||
# Frontend Architecture Anti-Patterns
|
||||
# Patterns that violate Mantimon TCG frontend architecture principles
|
||||
|
||||
name: Architecture
|
||||
description: |
|
||||
Detects violations of the project's frontend architecture principles:
|
||||
- Vue Composition API patterns (no Options API)
|
||||
- Pinia store patterns (setup syntax, proper actions)
|
||||
- Phaser integration (rendering only, no game logic)
|
||||
- Component organization (composables for reuse)
|
||||
- Backend authority (client never authoritative for game state)
|
||||
|
||||
patterns:
|
||||
# Vue Composition API
|
||||
- id: options-api-usage
|
||||
description: Options API instead of Composition API
|
||||
grep_pattern: 'export default \{[\s\S]*?(data\(\)|methods:|computed:|watch:)'
|
||||
multiline: true
|
||||
file_glob: '*.vue'
|
||||
context_lines: 5
|
||||
verdict_hints:
|
||||
ISSUE_IF: "Using Options API - project uses Composition API only"
|
||||
OK_IF: "Never OK - convert to <script setup> with Composition API"
|
||||
|
||||
- id: missing-script-setup
|
||||
description: Script without setup attribute
|
||||
grep_pattern: '<script lang="ts">\s*$'
|
||||
file_glob: '*.vue'
|
||||
context_lines: 2
|
||||
verdict_hints:
|
||||
ISSUE_IF: "Missing setup - should use <script setup lang=\"ts\">"
|
||||
OK_IF: "Rare cases needing defineComponent; document why"
|
||||
|
||||
- id: options-api-definecomponent
|
||||
description: defineComponent with options object
|
||||
grep_pattern: 'defineComponent\(\{[\s\S]*?(data|methods|computed):'
|
||||
multiline: true
|
||||
file_glob: '*.vue'
|
||||
context_lines: 5
|
||||
verdict_hints:
|
||||
ISSUE_IF: "Options API via defineComponent"
|
||||
OK_IF: "Never OK - use <script setup>"
|
||||
|
||||
# Pinia Store Patterns
|
||||
- id: store-options-syntax
|
||||
description: Pinia store using options syntax instead of setup
|
||||
grep_pattern: 'defineStore\([^,]+,\s*\{[\s\S]*?state:'
|
||||
multiline: true
|
||||
file_glob: 'stores/*.ts'
|
||||
context_lines: 5
|
||||
verdict_hints:
|
||||
ISSUE_IF: "Options syntax - project uses setup store syntax"
|
||||
OK_IF: "Never OK - convert to setup syntax with ref/computed"
|
||||
|
||||
- id: store-mutation-outside-action
|
||||
description: Store state mutated directly from component
|
||||
grep_pattern: 'Store\(\)\.\w+\s*='
|
||||
file_glob: '*.vue'
|
||||
context_lines: 3
|
||||
verdict_hints:
|
||||
ISSUE_IF: "Direct state mutation - use store action instead"
|
||||
OK_IF: "Assigning to local ref that shadows store property"
|
||||
|
||||
- id: store-no-error-state
|
||||
description: Store without error state for async operations
|
||||
grep_pattern: 'defineStore.*\{[\s\S]*?async.*fetch[\s\S]*?\}'
|
||||
multiline: true
|
||||
file_glob: 'stores/*.ts'
|
||||
context_lines: 20
|
||||
verdict_hints:
|
||||
ISSUE_IF: "Async store without error ref for failure handling"
|
||||
OK_IF: "Error state exists; or errors handled by caller"
|
||||
|
||||
# Phaser Integration
|
||||
- id: phaser-game-logic
|
||||
description: Game logic in Phaser scene (should be backend)
|
||||
grep_pattern: '(calculateDamage|resolveTurn|checkWin|applyEffect)'
|
||||
file_glob: 'game/**/*.ts'
|
||||
context_lines: 5
|
||||
verdict_hints:
|
||||
ISSUE_IF: "Game logic in Phaser - backend is authoritative"
|
||||
OK_IF: "Animation/display logic with same name; not actual game rules"
|
||||
|
||||
- id: phaser-direct-api
|
||||
description: Phaser scene making API calls directly
|
||||
grep_pattern: '(fetch|axios|api\.)'
|
||||
file_glob: 'game/**/*.ts'
|
||||
context_lines: 4
|
||||
verdict_hints:
|
||||
ISSUE_IF: "Phaser calling API - should go through Vue layer"
|
||||
OK_IF: "Asset loading (images, audio); not game data"
|
||||
|
||||
- id: phaser-store-import
|
||||
description: Phaser importing store directly (use bridge)
|
||||
grep_pattern: 'import.*from.*stores/'
|
||||
file_glob: 'game/**/*.ts'
|
||||
context_lines: 3
|
||||
verdict_hints:
|
||||
ISSUE_IF: "Direct store import - use event bridge pattern"
|
||||
OK_IF: "Type-only import (import type); bridge.ts file"
|
||||
|
||||
- id: phaser-modifies-state
|
||||
description: Phaser scene modifying game state
|
||||
grep_pattern: 'gameState\.\w+\s*='
|
||||
file_glob: 'game/**/*.ts'
|
||||
context_lines: 4
|
||||
verdict_hints:
|
||||
ISSUE_IF: "Phaser modifying state - should only render"
|
||||
OK_IF: "Local animation state; not shared game state"
|
||||
|
||||
# Component Organization
|
||||
- id: api-call-in-component
|
||||
description: Direct fetch/API call in component (use composable)
|
||||
grep_pattern: 'fetch\(|axios\.|api\.'
|
||||
file_glob: 'components/**/*.vue'
|
||||
context_lines: 5
|
||||
verdict_hints:
|
||||
ISSUE_IF: "API call in component - extract to composable"
|
||||
OK_IF: "Component is in pages/; or uses composable internally"
|
||||
|
||||
- id: large-component
|
||||
description: Component with many lines (consider splitting)
|
||||
grep_pattern: '</script>'
|
||||
file_glob: '*.vue'
|
||||
context_lines: 0
|
||||
verdict_hints:
|
||||
ISSUE_IF: "Script section >150 lines - split into composables"
|
||||
OK_IF: "Page component; or logic is cohesive and documented"
|
||||
|
||||
- id: prop-drilling
|
||||
description: Props passed through multiple levels
|
||||
grep_pattern: 'defineProps.*\{[\s\S]*?(Props|\.\.\.)'
|
||||
multiline: true
|
||||
file_glob: '*.vue'
|
||||
context_lines: 10
|
||||
verdict_hints:
|
||||
ISSUE_IF: "Props drilled >2 levels - use provide/inject or store"
|
||||
OK_IF: "Props are component-specific; not passed through"
|
||||
|
||||
# Type Safety
|
||||
- id: any-type-usage
|
||||
description: Using 'any' type
|
||||
grep_pattern: ':\s*any\b|as\s+any\b|<any>'
|
||||
file_glob: '*.{vue,ts}'
|
||||
context_lines: 2
|
||||
verdict_hints:
|
||||
ISSUE_IF: "Explicit any - defeats TypeScript benefits"
|
||||
OK_IF: "Temporary during migration; or external lib without types"
|
||||
|
||||
- id: type-assertion-abuse
|
||||
description: Type assertion (as) that might hide errors
|
||||
grep_pattern: '\s+as\s+[A-Z]\w+'
|
||||
file_glob: '*.{vue,ts}'
|
||||
context_lines: 3
|
||||
verdict_hints:
|
||||
ISSUE_IF: "Assertion used to bypass type error"
|
||||
OK_IF: "Narrowing from unknown; or after runtime check"
|
||||
|
||||
- id: missing-prop-types
|
||||
description: Props without TypeScript types
|
||||
grep_pattern: 'defineProps\(\[\s*["\']'
|
||||
file_glob: '*.vue'
|
||||
context_lines: 3
|
||||
verdict_hints:
|
||||
ISSUE_IF: "Array prop syntax - no type safety"
|
||||
OK_IF: "Never OK - use defineProps<{...}>()"
|
||||
|
||||
# Import Organization
|
||||
- id: relative-import-deep
|
||||
description: Deep relative imports (use @ alias)
|
||||
grep_pattern: 'from ["\']\.\.\/\.\.\/\.\.\/'
|
||||
file_glob: '*.{vue,ts}'
|
||||
context_lines: 2
|
||||
verdict_hints:
|
||||
ISSUE_IF: "Deep relative path - use @/ alias"
|
||||
OK_IF: "Never OK for 3+ levels - always use @/"
|
||||
|
||||
- id: missing-type-import
|
||||
description: Type import without 'import type'
|
||||
grep_pattern: 'import \{[^}]*(Type|Interface|Props)[^}]*\} from'
|
||||
file_glob: '*.{vue,ts}'
|
||||
context_lines: 2
|
||||
verdict_hints:
|
||||
ISSUE_IF: "Type imported as value - use 'import type'"
|
||||
OK_IF: "Also used as value (class, enum); not pure type"
|
||||
|
||||
# Backend Authority
|
||||
- id: client-state-authority
|
||||
description: Client treating its state as authoritative
|
||||
grep_pattern: '(emit|send)\([^)]*\{[^}]*(hp|damage|winner|gameState)'
|
||||
file_glob: '*.{vue,ts}'
|
||||
context_lines: 5
|
||||
verdict_hints:
|
||||
ISSUE_IF: "Sending computed game state to server - server is authority"
|
||||
OK_IF: "Sending action intent (playCard, attack); server computes result"
|
||||
|
||||
- id: local-game-calculation
|
||||
description: Calculating game outcomes client-side
|
||||
grep_pattern: '(damage|hp|energy)\s*[-+*]=|Math\.(random|floor).*damage'
|
||||
file_glob: '*.{vue,ts}'
|
||||
context_lines: 5
|
||||
verdict_hints:
|
||||
ISSUE_IF: "Game calculation on client - server must compute"
|
||||
OK_IF: "Animation preview; actual result from server"
|
||||
103
.claude/skills/frontend-code-audit/patterns/error-handling.yaml
Normal file
103
.claude/skills/frontend-code-audit/patterns/error-handling.yaml
Normal file
@ -0,0 +1,103 @@
|
||||
# Frontend Error Handling Anti-Patterns
|
||||
# Patterns that hide errors or fail to provide user feedback
|
||||
|
||||
name: Error Handling
|
||||
description: |
|
||||
Detects patterns where errors are silently swallowed, promises unhandled,
|
||||
or users left without feedback when operations fail. These patterns lead
|
||||
to confusing UX and hard-to-debug issues.
|
||||
|
||||
patterns:
|
||||
- id: unhandled-promise
|
||||
description: Async function called without await or .catch()
|
||||
grep_pattern: '^\s+\w+\([^)]*\)\s*$'
|
||||
file_glob: '*.{vue,ts}'
|
||||
context_lines: 3
|
||||
verdict_hints:
|
||||
ISSUE_IF: "Async function (fetch, API call) with no error handling"
|
||||
OK_IF: "Sync function; or fire-and-forget with documented reason"
|
||||
|
||||
- id: empty-catch-block
|
||||
description: Catch block that does nothing with the error
|
||||
grep_pattern: 'catch\s*\([^)]*\)\s*\{\s*\}'
|
||||
file_glob: '*.{vue,ts}'
|
||||
context_lines: 3
|
||||
verdict_hints:
|
||||
ISSUE_IF: "Error completely swallowed - user gets no feedback"
|
||||
OK_IF: "Never OK - at minimum log or set error state"
|
||||
|
||||
- id: catch-only-console
|
||||
description: Catch block that only logs to console
|
||||
grep_pattern: 'catch.*\{[\s\n]*console\.(log|error|warn)'
|
||||
multiline: true
|
||||
file_glob: '*.{vue,ts}'
|
||||
context_lines: 5
|
||||
verdict_hints:
|
||||
ISSUE_IF: "Error logged but UI shows success/no feedback to user"
|
||||
OK_IF: "Also sets error state or shows toast notification"
|
||||
|
||||
- id: fetch-no-error-check
|
||||
description: Fetch without checking response.ok
|
||||
grep_pattern: 'fetch\([^)]+\)[\s\S]*?\.json\(\)'
|
||||
multiline: true
|
||||
file_glob: '*.{vue,ts}'
|
||||
context_lines: 7
|
||||
verdict_hints:
|
||||
ISSUE_IF: "Response not checked for errors before parsing JSON"
|
||||
OK_IF: "Uses wrapper that handles errors; or response.ok checked"
|
||||
|
||||
- id: missing-loading-state
|
||||
description: Async operation without loading indicator
|
||||
grep_pattern: 'async.*\{[\s\S]*?await.*fetch'
|
||||
multiline: true
|
||||
file_glob: '*.vue'
|
||||
context_lines: 10
|
||||
verdict_hints:
|
||||
ISSUE_IF: "No isLoading ref set before/after async operation"
|
||||
OK_IF: "Loading state managed by composable; or instant operation"
|
||||
|
||||
- id: missing-error-state
|
||||
description: API call without error state handling
|
||||
grep_pattern: 'await.*(fetch|api|axios)'
|
||||
file_glob: '*.vue'
|
||||
context_lines: 8
|
||||
verdict_hints:
|
||||
ISSUE_IF: "No error ref or error handling visible in component"
|
||||
OK_IF: "Error handling in composable; or uses error boundary"
|
||||
|
||||
- id: silent-store-action
|
||||
description: Store action that catches but doesn't surface errors
|
||||
grep_pattern: 'catch.*\{[\s\S]*?return (false|null|undefined|\[\]|\{\})'
|
||||
multiline: true
|
||||
file_glob: 'stores/*.ts'
|
||||
context_lines: 6
|
||||
verdict_hints:
|
||||
ISSUE_IF: "Store swallows error, returns falsy - caller can't show error"
|
||||
OK_IF: "Also sets store.error state that UI can display"
|
||||
|
||||
- id: onmounted-no-error-handling
|
||||
description: onMounted with async call but no error handling
|
||||
grep_pattern: 'onMounted\(\s*async'
|
||||
file_glob: '*.vue'
|
||||
context_lines: 10
|
||||
verdict_hints:
|
||||
ISSUE_IF: "Async onMounted without try/catch - errors crash silently"
|
||||
OK_IF: "Has try/catch with error state; or uses error boundary"
|
||||
|
||||
- id: watch-async-no-catch
|
||||
description: Watch callback with async but no error handling
|
||||
grep_pattern: 'watch\([^)]+,\s*async'
|
||||
file_glob: '*.vue'
|
||||
context_lines: 8
|
||||
verdict_hints:
|
||||
ISSUE_IF: "Async watch without error handling"
|
||||
OK_IF: "Has try/catch; or errors handled by called function"
|
||||
|
||||
- id: socket-no-error-handler
|
||||
description: Socket.io without error event handler
|
||||
grep_pattern: 'socket\.(on|emit)\('
|
||||
file_glob: '*.{vue,ts}'
|
||||
context_lines: 10
|
||||
verdict_hints:
|
||||
ISSUE_IF: "Socket used without socket.on('error') or socket.on('connect_error')"
|
||||
OK_IF: "Error handlers registered elsewhere (socket setup file)"
|
||||
152
.claude/skills/frontend-code-audit/patterns/security.yaml
Normal file
152
.claude/skills/frontend-code-audit/patterns/security.yaml
Normal file
@ -0,0 +1,152 @@
|
||||
# Frontend Security Anti-Patterns
|
||||
# Patterns that may expose security vulnerabilities in Vue/TypeScript
|
||||
|
||||
name: Security
|
||||
description: |
|
||||
Detects patterns that could lead to security vulnerabilities including
|
||||
XSS risks, sensitive data exposure, insecure storage, and auth issues.
|
||||
Specific to Vue 3, TypeScript, and browser security concerns.
|
||||
|
||||
patterns:
|
||||
# XSS Risks
|
||||
- id: v-html-usage
|
||||
description: v-html directive (potential XSS)
|
||||
grep_pattern: 'v-html\s*='
|
||||
file_glob: '*.vue'
|
||||
context_lines: 3
|
||||
verdict_hints:
|
||||
ISSUE_IF: "Content from API, user input, or any external source"
|
||||
OK_IF: "Only static/trusted content; or sanitized with DOMPurify"
|
||||
|
||||
- id: innerhtml-assignment
|
||||
description: Direct innerHTML assignment
|
||||
grep_pattern: '\.innerHTML\s*='
|
||||
file_glob: '*.{vue,ts}'
|
||||
context_lines: 3
|
||||
verdict_hints:
|
||||
ISSUE_IF: "Assigns user-controlled or API content"
|
||||
OK_IF: "Static trusted HTML only; prefer v-html with sanitization"
|
||||
|
||||
- id: document-write
|
||||
description: document.write usage (XSS and performance)
|
||||
grep_pattern: 'document\.write'
|
||||
file_glob: '*.{vue,ts}'
|
||||
context_lines: 2
|
||||
verdict_hints:
|
||||
ISSUE_IF: "Almost never acceptable in modern apps"
|
||||
OK_IF: "Never OK - refactor to DOM manipulation"
|
||||
|
||||
# Sensitive Data Exposure
|
||||
- id: console-log-sensitive
|
||||
description: Console logging potentially sensitive data
|
||||
grep_pattern: 'console\.(log|debug|info)\(.*\b(token|password|secret|auth|credential|key)\b'
|
||||
file_glob: '*.{vue,ts}'
|
||||
context_lines: 2
|
||||
verdict_hints:
|
||||
ISSUE_IF: "Logs sensitive values - visible in browser devtools"
|
||||
OK_IF: "Logs field names only, not values; or dev-only code"
|
||||
|
||||
- id: localstorage-sensitive
|
||||
description: Storing sensitive data in localStorage
|
||||
grep_pattern: 'localStorage\.setItem\([^)]*\b(password|secret|credit|ssn)\b'
|
||||
file_glob: '*.{vue,ts}'
|
||||
context_lines: 3
|
||||
verdict_hints:
|
||||
ISSUE_IF: "Sensitive data in localStorage - accessible to XSS"
|
||||
OK_IF: "Never OK for passwords/secrets; tokens need careful review"
|
||||
|
||||
- id: token-in-url
|
||||
description: Auth token in URL or query params
|
||||
grep_pattern: '(url|href|src).*\?(.*&)?(token|auth|key)='
|
||||
file_glob: '*.{vue,ts}'
|
||||
context_lines: 3
|
||||
verdict_hints:
|
||||
ISSUE_IF: "Token in URL - logged in server logs, browser history"
|
||||
OK_IF: "One-time tokens (email verification); not session tokens"
|
||||
|
||||
- id: hardcoded-secret
|
||||
description: Hardcoded API key or secret
|
||||
grep_pattern: '(api_?key|secret|password)\s*[:=]\s*["\'][^"\']{8,}["\']'
|
||||
file_glob: '*.{vue,ts}'
|
||||
context_lines: 2
|
||||
exclude_tests: false
|
||||
verdict_hints:
|
||||
ISSUE_IF: "Real credential in source code"
|
||||
OK_IF: "Placeholder; real value from VITE_* env variable"
|
||||
|
||||
# Auth Issues
|
||||
- id: auth-check-client-only
|
||||
description: Auth check only on client side
|
||||
grep_pattern: '(isAuthenticated|isLoggedIn|user)\s*\?\s*<'
|
||||
file_glob: '*.vue'
|
||||
context_lines: 5
|
||||
verdict_hints:
|
||||
ISSUE_IF: "UI hidden but API not protected - security by obscurity"
|
||||
OK_IF: "UI hint only; API has proper auth middleware"
|
||||
|
||||
- id: token-in-state
|
||||
description: Auth token stored in reactive state without persistence consideration
|
||||
grep_pattern: 'ref<.*token.*>\s*\('
|
||||
file_glob: '*.{vue,ts}'
|
||||
context_lines: 4
|
||||
verdict_hints:
|
||||
ISSUE_IF: "Token in memory only - lost on refresh without secure persistence"
|
||||
OK_IF: "Intentional session-only token; or persisted securely elsewhere"
|
||||
|
||||
# Game-Specific Security
|
||||
- id: game-state-trust
|
||||
description: Trusting game state from client
|
||||
grep_pattern: '(damage|hp|energy|prize|winner)\s*=\s*(props|data|event)'
|
||||
file_glob: '*.{vue,ts}'
|
||||
context_lines: 5
|
||||
verdict_hints:
|
||||
ISSUE_IF: "Game state from client used without server validation"
|
||||
OK_IF: "Display only; actual state from server via WebSocket"
|
||||
|
||||
- id: hidden-info-client
|
||||
description: Accessing hidden game info that should be server-only
|
||||
grep_pattern: '\.(deck|opponentHand|prizes)\.(cards|contents|order)'
|
||||
file_glob: '*.{vue,ts}'
|
||||
context_lines: 4
|
||||
verdict_hints:
|
||||
ISSUE_IF: "Accessing hidden zone contents - should only have counts"
|
||||
OK_IF: "Own hand/prizes (visible to player); or mock data in tests"
|
||||
|
||||
# Input Validation
|
||||
- id: unsanitized-user-input
|
||||
description: User input used directly without validation
|
||||
grep_pattern: '(v-model|@input).*\n.*(?!.*validate|sanitize|escape)'
|
||||
multiline: true
|
||||
file_glob: '*.vue'
|
||||
context_lines: 5
|
||||
verdict_hints:
|
||||
ISSUE_IF: "Input sent to API without validation"
|
||||
OK_IF: "Validated before submission; or display-only local state"
|
||||
|
||||
- id: eval-usage
|
||||
description: eval() or Function() constructor
|
||||
grep_pattern: '\b(eval|Function)\s*\('
|
||||
file_glob: '*.{vue,ts}'
|
||||
context_lines: 3
|
||||
verdict_hints:
|
||||
ISSUE_IF: "Executing dynamic code - XSS/injection risk"
|
||||
OK_IF: "Never OK in frontend code"
|
||||
|
||||
# Network Security
|
||||
- id: http-url
|
||||
description: HTTP (non-HTTPS) URL in code
|
||||
grep_pattern: '["\']http://(?!localhost|127\.0\.0\.1)'
|
||||
file_glob: '*.{vue,ts}'
|
||||
context_lines: 2
|
||||
verdict_hints:
|
||||
ISSUE_IF: "Production URL using HTTP - data sent unencrypted"
|
||||
OK_IF: "Development URL; production uses HTTPS"
|
||||
|
||||
- id: cors-credential-any
|
||||
description: Credentials with wildcard or any origin
|
||||
grep_pattern: 'credentials:\s*["\']include["\']'
|
||||
file_glob: '*.{vue,ts}'
|
||||
context_lines: 4
|
||||
verdict_hints:
|
||||
ISSUE_IF: "Credentials sent to any origin - CSRF risk"
|
||||
OK_IF: "Server has strict CORS; or same-origin requests only"
|
||||
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal file
@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
5
frontend/README.md
Normal file
5
frontend/README.md
Normal file
@ -0,0 +1,5 @@
|
||||
# Vue 3 + TypeScript + Vite
|
||||
|
||||
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||
|
||||
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
|
||||
37
frontend/eslint.config.js
Normal file
37
frontend/eslint.config.js
Normal file
@ -0,0 +1,37 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import vue from 'eslint-plugin-vue'
|
||||
import tseslint from 'typescript-eslint'
|
||||
|
||||
export default [
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
...vue.configs['flat/recommended'],
|
||||
{
|
||||
files: ['**/*.{ts,vue}'],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.browser,
|
||||
},
|
||||
parserOptions: {
|
||||
parser: tseslint.parser,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
// Vue rules
|
||||
'vue/multi-word-component-names': 'off',
|
||||
'vue/require-default-prop': 'off',
|
||||
|
||||
// TypeScript rules
|
||||
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
|
||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
||||
|
||||
// General rules - allow console in dev guards
|
||||
'no-console': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
ignores: ['dist/', 'node_modules/', '*.config.js', '*.config.ts'],
|
||||
},
|
||||
]
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>mantimon-frontend-scaffold</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
5621
frontend/package-lock.json
generated
Normal file
5621
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
42
frontend/package.json
Normal file
42
frontend/package.json
Normal file
@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "mantimon-tcg-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest",
|
||||
"test:watch": "vitest --watch",
|
||||
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix",
|
||||
"typecheck": "vue-tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"pinia": "^3.0.2",
|
||||
"pinia-plugin-persistedstate": "^4.2.0",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"vue": "^3.5.24",
|
||||
"vue-router": "^4.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@types/node": "^24.10.1",
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"@vue/tsconfig": "^0.8.1",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"eslint": "^9.21.0",
|
||||
"eslint-plugin-vue": "^10.1.0",
|
||||
"globals": "^17.2.0",
|
||||
"jsdom": "^27.4.0",
|
||||
"postcss": "^8.5.3",
|
||||
"tailwindcss": "^4.1.4",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.54.0",
|
||||
"vite": "^7.2.4",
|
||||
"vitest": "^3.1.2",
|
||||
"vue-tsc": "^3.1.4"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
1
frontend/public/vite.svg
Normal file
1
frontend/public/vite.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
20
frontend/src/App.vue
Normal file
20
frontend/src/App.vue
Normal file
@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import AppHeader from '@/components/AppHeader.vue'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
// Hide header on match page for full-screen game view
|
||||
const showHeader = computed(() => route.name !== 'match')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen flex flex-col">
|
||||
<AppHeader v-if="showHeader" />
|
||||
<main class="flex-1">
|
||||
<RouterView />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
74
frontend/src/assets/main.css
Normal file
74
frontend/src/assets/main.css
Normal file
@ -0,0 +1,74 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* Import pixel font for retro GBC aesthetic */
|
||||
@import url('https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap');
|
||||
|
||||
/* Custom theme */
|
||||
@theme {
|
||||
/* Pokemon type colors */
|
||||
--color-type-grass: #78C850;
|
||||
--color-type-fire: #F08030;
|
||||
--color-type-water: #6890F0;
|
||||
--color-type-lightning: #F8D030;
|
||||
--color-type-psychic: #F85888;
|
||||
--color-type-fighting: #C03028;
|
||||
--color-type-darkness: #705848;
|
||||
--color-type-metal: #B8B8D0;
|
||||
--color-type-fairy: #EE99AC;
|
||||
--color-type-dragon: #7038F8;
|
||||
--color-type-colorless: #A8A878;
|
||||
|
||||
/* UI colors */
|
||||
--color-primary: #3B82F6;
|
||||
--color-primary-dark: #2563EB;
|
||||
--color-primary-light: #60A5FA;
|
||||
--color-secondary: #6366F1;
|
||||
--color-secondary-dark: #4F46E5;
|
||||
--color-secondary-light: #818CF8;
|
||||
--color-success: #22C55E;
|
||||
--color-warning: #F59E0B;
|
||||
--color-error: #EF4444;
|
||||
|
||||
/* Dark theme surfaces */
|
||||
--color-surface: #374151;
|
||||
--color-surface-dark: #1F2937;
|
||||
--color-surface-light: #4B5563;
|
||||
|
||||
/* Font family */
|
||||
--font-pixel: "Press Start 2P", cursive;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
@apply bg-gray-900 text-gray-100;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply min-h-screen;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* Card styling */
|
||||
.card {
|
||||
@apply bg-surface rounded-lg shadow-lg overflow-hidden;
|
||||
}
|
||||
|
||||
/* Button variants */
|
||||
.btn {
|
||||
@apply px-4 py-2 rounded font-medium transition-colors duration-200;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply bg-primary hover:bg-primary-dark text-white;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply bg-secondary hover:bg-secondary-dark text-white;
|
||||
}
|
||||
|
||||
/* Type badges */
|
||||
.type-badge {
|
||||
@apply px-2 py-1 rounded text-xs font-bold uppercase;
|
||||
}
|
||||
}
|
||||
78
frontend/src/components/AppHeader.vue
Normal file
78
frontend/src/components/AppHeader.vue
Normal file
@ -0,0 +1,78 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { RouterLink, useRouter } from 'vue-router'
|
||||
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const router = useRouter()
|
||||
|
||||
const isAuthenticated = computed(() => authStore.isAuthenticated)
|
||||
const username = computed(() => authStore.user?.username)
|
||||
|
||||
function handleLogout() {
|
||||
authStore.logout()
|
||||
router.push({ name: 'home' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="bg-surface-dark border-b border-gray-700">
|
||||
<div class="container mx-auto px-4 py-3 flex items-center justify-between">
|
||||
<RouterLink
|
||||
to="/"
|
||||
class="text-xl font-bold text-primary-light"
|
||||
>
|
||||
Mantimon TCG
|
||||
</RouterLink>
|
||||
|
||||
<nav class="flex items-center gap-6">
|
||||
<template v-if="isAuthenticated">
|
||||
<RouterLink
|
||||
to="/campaign"
|
||||
class="text-gray-300 hover:text-white transition-colors"
|
||||
>
|
||||
Campaign
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
to="/collection"
|
||||
class="text-gray-300 hover:text-white transition-colors"
|
||||
>
|
||||
Collection
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
to="/deck-builder"
|
||||
class="text-gray-300 hover:text-white transition-colors"
|
||||
>
|
||||
Decks
|
||||
</RouterLink>
|
||||
|
||||
<div class="flex items-center gap-3 ml-4 pl-4 border-l border-gray-600">
|
||||
<span class="text-gray-400">{{ username }}</span>
|
||||
<button
|
||||
class="text-gray-400 hover:text-white transition-colors"
|
||||
@click="handleLogout"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<RouterLink
|
||||
to="/login"
|
||||
class="text-gray-300 hover:text-white transition-colors"
|
||||
>
|
||||
Login
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
to="/register"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
Sign Up
|
||||
</RouterLink>
|
||||
</template>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
19
frontend/src/main.ts
Normal file
19
frontend/src/main.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { createApp } from 'vue'
|
||||
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import pinia from './stores'
|
||||
import { useAuthStore } from './stores/auth'
|
||||
|
||||
import './assets/main.css'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(pinia)
|
||||
app.use(router)
|
||||
|
||||
// Initialize auth state from localStorage
|
||||
const authStore = useAuthStore()
|
||||
authStore.init()
|
||||
|
||||
app.mount('#app')
|
||||
20
frontend/src/pages/CampaignPage.vue
Normal file
20
frontend/src/pages/CampaignPage.vue
Normal file
@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
// Campaign page - placeholder for Phase F2
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<h1 class="text-3xl font-bold mb-8">
|
||||
Campaign
|
||||
</h1>
|
||||
|
||||
<div class="bg-surface p-8 rounded-lg text-center">
|
||||
<p class="text-gray-400 mb-4">
|
||||
Campaign mode coming in Phase F2
|
||||
</p>
|
||||
<p class="text-sm text-gray-500">
|
||||
Challenge NPCs, defeat Club Leaders, and become Champion!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
20
frontend/src/pages/CollectionPage.vue
Normal file
20
frontend/src/pages/CollectionPage.vue
Normal file
@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
// Collection page - placeholder for Phase F3
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<h1 class="text-3xl font-bold mb-8">
|
||||
Collection
|
||||
</h1>
|
||||
|
||||
<div class="bg-surface p-8 rounded-lg text-center">
|
||||
<p class="text-gray-400 mb-4">
|
||||
Collection view coming in Phase F3
|
||||
</p>
|
||||
<p class="text-sm text-gray-500">
|
||||
Browse your cards, filter by type, and track your collection progress.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
20
frontend/src/pages/DeckBuilderPage.vue
Normal file
20
frontend/src/pages/DeckBuilderPage.vue
Normal file
@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
// Deck builder page - placeholder for Phase F3
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<h1 class="text-3xl font-bold mb-8">
|
||||
Deck Builder
|
||||
</h1>
|
||||
|
||||
<div class="bg-surface p-8 rounded-lg text-center">
|
||||
<p class="text-gray-400 mb-4">
|
||||
Deck builder coming in Phase F3
|
||||
</p>
|
||||
<p class="text-sm text-gray-500">
|
||||
Build and manage your decks with drag-and-drop interface.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
86
frontend/src/pages/HomePage.vue
Normal file
86
frontend/src/pages/HomePage.vue
Normal file
@ -0,0 +1,86 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const isAuthenticated = computed(() => authStore.isAuthenticated)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="container mx-auto px-4 py-16">
|
||||
<div class="max-w-2xl mx-auto text-center">
|
||||
<h1 class="text-4xl md:text-5xl font-bold text-primary-light mb-6">
|
||||
Mantimon TCG
|
||||
</h1>
|
||||
<p class="text-xl text-gray-300 mb-8">
|
||||
A single-player trading card game adventure inspired by the
|
||||
classic Gameboy Color Pokemon TCG.
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-if="isAuthenticated"
|
||||
class="space-y-4"
|
||||
>
|
||||
<RouterLink
|
||||
to="/campaign"
|
||||
class="btn btn-primary block w-full text-lg py-3"
|
||||
>
|
||||
Continue Campaign
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
to="/collection"
|
||||
class="btn btn-secondary block w-full text-lg py-3"
|
||||
>
|
||||
View Collection
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="space-y-4"
|
||||
>
|
||||
<RouterLink
|
||||
to="/register"
|
||||
class="btn btn-primary block w-full text-lg py-3"
|
||||
>
|
||||
Start Your Journey
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
to="/login"
|
||||
class="btn btn-secondary block w-full text-lg py-3"
|
||||
>
|
||||
Continue Adventure
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<div class="mt-16 grid grid-cols-1 md:grid-cols-3 gap-8 text-left">
|
||||
<div class="bg-surface p-6 rounded-lg">
|
||||
<h3 class="text-lg font-bold text-type-fire mb-2">
|
||||
Campaign Mode
|
||||
</h3>
|
||||
<p class="text-gray-400">
|
||||
Challenge NPCs at themed clubs, defeat leaders, and collect medals.
|
||||
</p>
|
||||
</div>
|
||||
<div class="bg-surface p-6 rounded-lg">
|
||||
<h3 class="text-lg font-bold text-type-water mb-2">
|
||||
Build Your Deck
|
||||
</h3>
|
||||
<p class="text-gray-400">
|
||||
Collect cards from booster packs and build powerful decks.
|
||||
</p>
|
||||
</div>
|
||||
<div class="bg-surface p-6 rounded-lg">
|
||||
<h3 class="text-lg font-bold text-type-lightning mb-2">
|
||||
PvP Battles
|
||||
</h3>
|
||||
<p class="text-gray-400">
|
||||
Test your skills against other players in multiplayer matches.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
93
frontend/src/pages/LoginPage.vue
Normal file
93
frontend/src/pages/LoginPage.vue
Normal file
@ -0,0 +1,93 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
|
||||
async function handleSubmit() {
|
||||
const success = await authStore.login(username.value, password.value)
|
||||
if (success) {
|
||||
const redirect = route.query.redirect as string
|
||||
router.push(redirect || '/campaign')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="container mx-auto px-4 py-16">
|
||||
<div class="max-w-md mx-auto">
|
||||
<h1 class="text-3xl font-bold text-center mb-8">
|
||||
Login
|
||||
</h1>
|
||||
|
||||
<form
|
||||
class="space-y-6"
|
||||
@submit.prevent="handleSubmit"
|
||||
>
|
||||
<div
|
||||
v-if="authStore.error"
|
||||
class="bg-error/20 text-error p-4 rounded"
|
||||
>
|
||||
{{ authStore.error }}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="username"
|
||||
class="block text-sm font-medium mb-2"
|
||||
>
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
id="username"
|
||||
v-model="username"
|
||||
type="text"
|
||||
required
|
||||
class="w-full px-4 py-2 bg-surface rounded border border-gray-600 focus:border-primary focus:outline-none"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="password"
|
||||
class="block text-sm font-medium mb-2"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
v-model="password"
|
||||
type="password"
|
||||
required
|
||||
class="w-full px-4 py-2 bg-surface rounded border border-gray-600 focus:border-primary focus:outline-none"
|
||||
>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="authStore.isLoading"
|
||||
class="btn btn-primary w-full py-3"
|
||||
>
|
||||
{{ authStore.isLoading ? 'Logging in...' : 'Login' }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="mt-6 text-center text-gray-400">
|
||||
Don't have an account?
|
||||
<RouterLink
|
||||
to="/register"
|
||||
class="text-primary-light hover:underline"
|
||||
>
|
||||
Sign up
|
||||
</RouterLink>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
55
frontend/src/pages/MatchPage.vue
Normal file
55
frontend/src/pages/MatchPage.vue
Normal file
@ -0,0 +1,55 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { useGameStore } from '@/stores/game'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const gameStore = useGameStore()
|
||||
|
||||
const matchId = route.params.id as string | undefined
|
||||
|
||||
onMounted(() => {
|
||||
if (!matchId) {
|
||||
// No match ID - redirect to campaign
|
||||
router.push('/campaign')
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: Connect to game via WebSocket (Phase F4)
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('Joining match:', matchId)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
gameStore.clearGame()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-screen bg-surface-dark flex items-center justify-center">
|
||||
<div
|
||||
v-if="!gameStore.gameState"
|
||||
class="text-center"
|
||||
>
|
||||
<div class="animate-pulse mb-4">
|
||||
<div class="w-16 h-16 mx-auto bg-primary rounded-full" />
|
||||
</div>
|
||||
<p class="text-gray-400">
|
||||
Connecting to match...
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="w-full h-full"
|
||||
>
|
||||
<!-- Phaser game canvas will mount here in Phase F4 -->
|
||||
<p class="text-center text-gray-400 pt-8">
|
||||
Match UI coming in Phase F4
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
142
frontend/src/pages/RegisterPage.vue
Normal file
142
frontend/src/pages/RegisterPage.vue
Normal file
@ -0,0 +1,142 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const username = ref('')
|
||||
const email = ref('')
|
||||
const password = ref('')
|
||||
const confirmPassword = ref('')
|
||||
const localError = ref<string | null>(null)
|
||||
|
||||
async function handleSubmit() {
|
||||
localError.value = null
|
||||
|
||||
if (password.value !== confirmPassword.value) {
|
||||
localError.value = 'Passwords do not match'
|
||||
return
|
||||
}
|
||||
|
||||
if (password.value.length < 8) {
|
||||
localError.value = 'Password must be at least 8 characters'
|
||||
return
|
||||
}
|
||||
|
||||
const success = await authStore.register(
|
||||
username.value,
|
||||
email.value,
|
||||
password.value
|
||||
)
|
||||
if (success) {
|
||||
router.push('/campaign')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="container mx-auto px-4 py-16">
|
||||
<div class="max-w-md mx-auto">
|
||||
<h1 class="text-3xl font-bold text-center mb-8">
|
||||
Create Account
|
||||
</h1>
|
||||
|
||||
<form
|
||||
class="space-y-6"
|
||||
@submit.prevent="handleSubmit"
|
||||
>
|
||||
<div
|
||||
v-if="localError || authStore.error"
|
||||
class="bg-error/20 text-error p-4 rounded"
|
||||
>
|
||||
{{ localError || authStore.error }}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="username"
|
||||
class="block text-sm font-medium mb-2"
|
||||
>
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
id="username"
|
||||
v-model="username"
|
||||
type="text"
|
||||
required
|
||||
class="w-full px-4 py-2 bg-surface rounded border border-gray-600 focus:border-primary focus:outline-none"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="email"
|
||||
class="block text-sm font-medium mb-2"
|
||||
>
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
v-model="email"
|
||||
type="email"
|
||||
required
|
||||
class="w-full px-4 py-2 bg-surface rounded border border-gray-600 focus:border-primary focus:outline-none"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="password"
|
||||
class="block text-sm font-medium mb-2"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
v-model="password"
|
||||
type="password"
|
||||
required
|
||||
class="w-full px-4 py-2 bg-surface rounded border border-gray-600 focus:border-primary focus:outline-none"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="confirmPassword"
|
||||
class="block text-sm font-medium mb-2"
|
||||
>
|
||||
Confirm Password
|
||||
</label>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
v-model="confirmPassword"
|
||||
type="password"
|
||||
required
|
||||
class="w-full px-4 py-2 bg-surface rounded border border-gray-600 focus:border-primary focus:outline-none"
|
||||
>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="authStore.isLoading"
|
||||
class="btn btn-primary w-full py-3"
|
||||
>
|
||||
{{ authStore.isLoading ? 'Creating account...' : 'Create Account' }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="mt-6 text-center text-gray-400">
|
||||
Already have an account?
|
||||
<RouterLink
|
||||
to="/login"
|
||||
class="text-primary-light hover:underline"
|
||||
>
|
||||
Login
|
||||
</RouterLink>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
63
frontend/src/router/index.ts
Normal file
63
frontend/src/router/index.ts
Normal file
@ -0,0 +1,63 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'home',
|
||||
component: () => import('@/pages/HomePage.vue'),
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: () => import('@/pages/LoginPage.vue'),
|
||||
},
|
||||
{
|
||||
path: '/register',
|
||||
name: 'register',
|
||||
component: () => import('@/pages/RegisterPage.vue'),
|
||||
},
|
||||
{
|
||||
path: '/campaign',
|
||||
name: 'campaign',
|
||||
component: () => import('@/pages/CampaignPage.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/collection',
|
||||
name: 'collection',
|
||||
component: () => import('@/pages/CollectionPage.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/deck-builder',
|
||||
name: 'deck-builder',
|
||||
component: () => import('@/pages/DeckBuilderPage.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/match/:id?',
|
||||
name: 'match',
|
||||
component: () => import('@/pages/MatchPage.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
})
|
||||
|
||||
// Navigation guard for auth
|
||||
router.beforeEach((to, _from, next) => {
|
||||
const isAuthenticated = localStorage.getItem('auth_token')
|
||||
|
||||
if (to.meta.requiresAuth && !isAuthenticated) {
|
||||
next({ name: 'login', query: { redirect: to.fullPath } })
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
48
frontend/src/stores/auth.spec.ts
Normal file
48
frontend/src/stores/auth.spec.ts
Normal file
@ -0,0 +1,48 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
|
||||
import { useAuthStore } from './auth'
|
||||
|
||||
describe('useAuthStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
it('starts with no authenticated user', () => {
|
||||
/**
|
||||
* Test that the auth store initializes in an unauthenticated state.
|
||||
*
|
||||
* New users should not be authenticated until they log in,
|
||||
* ensuring protected routes are inaccessible by default.
|
||||
*/
|
||||
const store = useAuthStore()
|
||||
|
||||
expect(store.isAuthenticated).toBe(false)
|
||||
expect(store.user).toBeNull()
|
||||
expect(store.token).toBeNull()
|
||||
})
|
||||
|
||||
it('tracks loading state', () => {
|
||||
/**
|
||||
* Test that the loading state ref is accessible.
|
||||
*
|
||||
* Components need to check loading state to show spinners
|
||||
* and disable buttons during async operations.
|
||||
*/
|
||||
const store = useAuthStore()
|
||||
|
||||
expect(store.isLoading).toBe(false)
|
||||
})
|
||||
|
||||
it('tracks error state', () => {
|
||||
/**
|
||||
* Test that the error state ref is accessible.
|
||||
*
|
||||
* Components need to display error messages when
|
||||
* login or registration fails.
|
||||
*/
|
||||
const store = useAuthStore()
|
||||
|
||||
expect(store.error).toBeNull()
|
||||
})
|
||||
})
|
||||
112
frontend/src/stores/auth.ts
Normal file
112
frontend/src/stores/auth.ts
Normal file
@ -0,0 +1,112 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
export interface User {
|
||||
id: string
|
||||
username: string
|
||||
email: string
|
||||
}
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const user = ref<User | null>(null)
|
||||
const token = ref<string | null>(null)
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const isAuthenticated = computed(() => !!token.value)
|
||||
|
||||
async function login(username: string, password: string): Promise<boolean> {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json()
|
||||
throw new Error(data.detail || 'Login failed')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
token.value = data.access_token
|
||||
user.value = data.user
|
||||
// Note: localStorage is used for token persistence across page reloads.
|
||||
// This is standard for SPAs but tokens are accessible to XSS. The backend
|
||||
// should use short-lived access tokens + httpOnly refresh cookies for
|
||||
// production. See: https://auth0.com/docs/secure/security-guidance/data-security/token-storage
|
||||
localStorage.setItem('auth_token', data.access_token)
|
||||
|
||||
return true
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Login failed'
|
||||
return false
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function register(
|
||||
username: string,
|
||||
email: string,
|
||||
password: string
|
||||
): Promise<boolean> {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/auth/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, email, password }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json()
|
||||
throw new Error(data.detail || 'Registration failed')
|
||||
}
|
||||
|
||||
// Auto-login after registration
|
||||
return await login(username, password)
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Registration failed'
|
||||
return false
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function logout() {
|
||||
user.value = null
|
||||
token.value = null
|
||||
localStorage.removeItem('auth_token')
|
||||
}
|
||||
|
||||
// Restore token from localStorage on init
|
||||
function init() {
|
||||
const storedToken = localStorage.getItem('auth_token')
|
||||
if (storedToken) {
|
||||
token.value = storedToken
|
||||
// TODO: Validate token and fetch user profile
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
user,
|
||||
token,
|
||||
isLoading,
|
||||
error,
|
||||
isAuthenticated,
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
init,
|
||||
}
|
||||
}, {
|
||||
persist: {
|
||||
pick: ['token'],
|
||||
},
|
||||
})
|
||||
74
frontend/src/stores/game.ts
Normal file
74
frontend/src/stores/game.ts
Normal file
@ -0,0 +1,74 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import type { GameState, Card, Player } from '@/types'
|
||||
|
||||
export const useGameStore = defineStore('game', () => {
|
||||
const gameState = ref<GameState | null>(null)
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
// Computed properties for current player's view
|
||||
const myPlayer = computed<Player | null>(() => {
|
||||
if (!gameState.value) return null
|
||||
return gameState.value.players.find(p => p.isMe) ?? null
|
||||
})
|
||||
|
||||
const opponent = computed<Player | null>(() => {
|
||||
if (!gameState.value) return null
|
||||
return gameState.value.players.find(p => !p.isMe) ?? null
|
||||
})
|
||||
|
||||
const myHand = computed<Card[]>(() => myPlayer.value?.hand ?? [])
|
||||
|
||||
const myActive = computed<Card | null>(() => myPlayer.value?.active ?? null)
|
||||
|
||||
const myBench = computed<Card[]>(() => myPlayer.value?.bench ?? [])
|
||||
|
||||
const isMyTurn = computed(() => {
|
||||
if (!gameState.value || !myPlayer.value) return false
|
||||
return gameState.value.currentPlayerId === myPlayer.value.id
|
||||
})
|
||||
|
||||
const currentPhase = computed(() => gameState.value?.phase ?? null)
|
||||
|
||||
// Actions
|
||||
function updateGameState(state: GameState) {
|
||||
gameState.value = state
|
||||
}
|
||||
|
||||
function clearGame() {
|
||||
gameState.value = null
|
||||
error.value = null
|
||||
}
|
||||
|
||||
function setError(message: string) {
|
||||
error.value = message
|
||||
}
|
||||
|
||||
function clearError() {
|
||||
error.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
gameState,
|
||||
isLoading,
|
||||
error,
|
||||
|
||||
// Computed
|
||||
myPlayer,
|
||||
opponent,
|
||||
myHand,
|
||||
myActive,
|
||||
myBench,
|
||||
isMyTurn,
|
||||
currentPhase,
|
||||
|
||||
// Actions
|
||||
updateGameState,
|
||||
clearGame,
|
||||
setError,
|
||||
clearError,
|
||||
}
|
||||
})
|
||||
11
frontend/src/stores/index.ts
Normal file
11
frontend/src/stores/index.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { createPinia } from 'pinia'
|
||||
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
|
||||
|
||||
const pinia = createPinia()
|
||||
pinia.use(piniaPluginPersistedstate)
|
||||
|
||||
export default pinia
|
||||
|
||||
// Re-export stores for convenience
|
||||
export { useAuthStore } from './auth'
|
||||
export { useGameStore } from './game'
|
||||
148
frontend/src/types/index.ts
Normal file
148
frontend/src/types/index.ts
Normal file
@ -0,0 +1,148 @@
|
||||
// Card Types
|
||||
export type CardType =
|
||||
| 'grass'
|
||||
| 'fire'
|
||||
| 'water'
|
||||
| 'lightning'
|
||||
| 'psychic'
|
||||
| 'fighting'
|
||||
| 'darkness'
|
||||
| 'metal'
|
||||
| 'fairy'
|
||||
| 'dragon'
|
||||
| 'colorless'
|
||||
|
||||
export type CardCategory = 'pokemon' | 'trainer' | 'energy'
|
||||
|
||||
export interface Card {
|
||||
id: string
|
||||
definitionId: string
|
||||
name: string
|
||||
category: CardCategory
|
||||
type?: CardType
|
||||
hp?: number
|
||||
currentHp?: number
|
||||
attacks?: Attack[]
|
||||
imageUrl?: string
|
||||
// Status effects for Pokemon
|
||||
status?: StatusEffect[]
|
||||
// Attached cards (energy, tools)
|
||||
attachments?: Card[]
|
||||
}
|
||||
|
||||
export interface Attack {
|
||||
name: string
|
||||
cost: CardType[]
|
||||
damage: number
|
||||
effect?: string
|
||||
effectParams?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface StatusEffect {
|
||||
type: 'poisoned' | 'burned' | 'asleep' | 'paralyzed' | 'confused'
|
||||
turnsRemaining?: number
|
||||
}
|
||||
|
||||
// Game State Types
|
||||
export type GamePhase = 'setup' | 'draw' | 'main' | 'attack' | 'end'
|
||||
|
||||
export interface Player {
|
||||
id: string
|
||||
username: string
|
||||
isMe: boolean
|
||||
hand: Card[]
|
||||
active: Card | null
|
||||
bench: Card[]
|
||||
prizes: Card[]
|
||||
prizeCount: number
|
||||
deckCount: number
|
||||
discardPile: Card[]
|
||||
}
|
||||
|
||||
export interface GameState {
|
||||
id: string
|
||||
players: Player[]
|
||||
currentPlayerId: string
|
||||
phase: GamePhase
|
||||
turnNumber: number
|
||||
winner?: string
|
||||
// Actions available to current player
|
||||
availableActions?: string[]
|
||||
}
|
||||
|
||||
// API Response Types
|
||||
export interface ApiError {
|
||||
detail: string
|
||||
code?: string
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
items: T[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
totalPages: number
|
||||
}
|
||||
|
||||
// Collection Types
|
||||
export interface CollectionCard {
|
||||
cardDefinitionId: string
|
||||
quantity: number
|
||||
card: CardDefinition
|
||||
}
|
||||
|
||||
export interface CardDefinition {
|
||||
id: string
|
||||
name: string
|
||||
category: CardCategory
|
||||
type?: CardType
|
||||
hp?: number
|
||||
attacks?: Attack[]
|
||||
imageUrl: string
|
||||
rarity: 'common' | 'uncommon' | 'rare' | 'holo' | 'ultra'
|
||||
setId: string
|
||||
setNumber: number
|
||||
}
|
||||
|
||||
// Deck Types
|
||||
export interface Deck {
|
||||
id: string
|
||||
name: string
|
||||
cards: DeckCard[]
|
||||
isValid: boolean
|
||||
cardCount: number
|
||||
}
|
||||
|
||||
export interface DeckCard {
|
||||
cardDefinitionId: string
|
||||
quantity: number
|
||||
card: CardDefinition
|
||||
}
|
||||
|
||||
// Campaign Types
|
||||
export interface Club {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
iconUrl: string
|
||||
members: ClubMember[]
|
||||
leader: ClubMember
|
||||
isDefeated: boolean
|
||||
}
|
||||
|
||||
export interface ClubMember {
|
||||
id: string
|
||||
name: string
|
||||
portraitUrl: string
|
||||
difficulty: 'easy' | 'medium' | 'hard' | 'leader'
|
||||
isDefeated: boolean
|
||||
deckTheme: CardType
|
||||
}
|
||||
|
||||
export interface CampaignProgress {
|
||||
clubsDefeated: string[]
|
||||
medalsEarned: string[]
|
||||
currentClub?: string
|
||||
totalWins: number
|
||||
totalLosses: number
|
||||
}
|
||||
22
frontend/tsconfig.app.json
Normal file
22
frontend/tsconfig.app.json
Normal file
@ -0,0 +1,22 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"types": ["vite/client"],
|
||||
|
||||
/* Path Aliases */
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
},
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||
}
|
||||
7
frontend/tsconfig.json
Normal file
7
frontend/tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
frontend/tsconfig.node.json
Normal file
26
frontend/tsconfig.node.json
Normal file
@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
27
frontend/vite.config.ts
Normal file
27
frontend/vite.config.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||
}
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true
|
||||
},
|
||||
'/socket.io': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
ws: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
17
frontend/vitest.config.ts
Normal file
17
frontend/vitest.config.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
include: ['src/**/*.{test,spec}.{js,ts}'],
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||
}
|
||||
}
|
||||
})
|
||||
Loading…
Reference in New Issue
Block a user