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