add infinite scrolling to explore and profile pages
This commit is contained in:
parent
8f6a8ac522
commit
999f03f42c
21 changed files with 7453 additions and 34 deletions
161
docs/adapter-migration-plan.md
Normal file
161
docs/adapter-migration-plan.md
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
# Migration Plan: Replace API Core with Adapters
|
||||
|
||||
## Overview
|
||||
We need to migrate 32 files from using the old `$lib/api/core` to our new adapter system. The migration involves replacing direct API calls with adapter methods and updating imports.
|
||||
|
||||
## Progress Tracking
|
||||
|
||||
### Phase 1: Core API Resources (2 files) ✅ COMPLETED
|
||||
- [x] **`lib/api/resources/parties.ts`** (1 import) - Uses get, post, put, del, buildUrl
|
||||
- ✅ Deleted - functionality moved to PartyAdapter
|
||||
- [x] **`lib/api/resources/grid.ts`** (1 import) - Uses buildUrl
|
||||
- ✅ Deleted - functionality moved to GridAdapter
|
||||
|
||||
### Phase 2: Services (3 files) ✅ COMPLETED
|
||||
- [x] **`lib/services/party.service.ts`** (1 import) - Uses FetchLike type
|
||||
- ✅ Updated to use PartyAdapter directly
|
||||
- [x] **`lib/services/grid.service.ts`** (1 import) - Uses FetchLike type
|
||||
- ✅ Updated to use GridAdapter directly
|
||||
- [x] **`lib/services/conflict.service.ts`** (1 import) - Uses FetchLike type
|
||||
- ✅ Updated to use GridAdapter conflict resolution methods
|
||||
|
||||
### Phase 3: API Route Handlers (20 files) ✅ COMPLETED
|
||||
|
||||
#### Party routes:
|
||||
- [x] `routes/api/parties/+server.ts` - Create/list parties
|
||||
- ✅ Updated to use buildApiUrl utility
|
||||
- [x] `routes/api/parties/[id]/+server.ts` - Update/delete party
|
||||
- ✅ Updated to use buildApiUrl utility
|
||||
|
||||
#### Grid weapon routes:
|
||||
- [x] `routes/api/parties/[id]/grid_weapons/+server.ts`
|
||||
- ✅ Updated to use buildApiUrl utility
|
||||
- [x] `routes/api/parties/[id]/grid_weapons/[weaponId]/position/+server.ts`
|
||||
- ✅ Updated to use buildApiUrl utility
|
||||
- [x] `routes/api/parties/[id]/grid_weapons/swap/+server.ts`
|
||||
- ✅ Updated to use buildApiUrl utility
|
||||
- [x] `routes/api/parties/[id]/weapons/+server.ts` (old endpoint)
|
||||
- ✅ Updated to use buildApiUrl utility
|
||||
- [x] `routes/api/parties/[id]/weapons/[weaponId]/+server.ts` (old endpoint)
|
||||
- ✅ Updated to use buildApiUrl utility
|
||||
|
||||
#### Grid character routes:
|
||||
- [x] `routes/api/parties/[id]/grid_characters/+server.ts`
|
||||
- ✅ Updated to use buildApiUrl utility
|
||||
- [x] `routes/api/parties/[id]/grid_characters/[characterId]/position/+server.ts`
|
||||
- ✅ Updated to use buildApiUrl utility
|
||||
- [x] `routes/api/parties/[id]/grid_characters/swap/+server.ts`
|
||||
- ✅ Updated to use buildApiUrl utility
|
||||
- [x] `routes/api/parties/[id]/characters/+server.ts` (old endpoint)
|
||||
- ✅ Updated to use buildApiUrl utility
|
||||
- [x] `routes/api/parties/[id]/characters/[characterId]/+server.ts` (old endpoint)
|
||||
- ✅ Updated to use buildApiUrl utility
|
||||
|
||||
#### Grid summon routes:
|
||||
- [x] `routes/api/parties/[id]/grid_summons/+server.ts`
|
||||
- ✅ Updated to use buildApiUrl utility
|
||||
- [x] `routes/api/parties/[id]/grid_summons/[summonId]/position/+server.ts`
|
||||
- ✅ Updated to use buildApiUrl utility
|
||||
- [x] `routes/api/parties/[id]/grid_summons/swap/+server.ts`
|
||||
- ✅ Updated to use buildApiUrl utility
|
||||
- [x] `routes/api/parties/[id]/summons/+server.ts` (old endpoint)
|
||||
- ✅ Updated to use buildApiUrl utility
|
||||
- [x] `routes/api/parties/[id]/summons/[summonId]/+server.ts` (old endpoint)
|
||||
- ✅ Updated to use buildApiUrl utility
|
||||
|
||||
#### Uncap routes:
|
||||
- [x] `routes/api/uncap/weapons/+server.ts`
|
||||
- ✅ Updated to use buildApiUrl utility
|
||||
- [x] `routes/api/uncap/characters/+server.ts`
|
||||
- ✅ Updated to use buildApiUrl utility
|
||||
- [x] `routes/api/uncap/summons/+server.ts`
|
||||
- ✅ Updated to use buildApiUrl utility
|
||||
|
||||
### Phase 4: Page Server Components (3 files) ✅ COMPLETED
|
||||
- [x] **`routes/database/weapons/[id]/+page.server.ts`** - Uses get from api/core
|
||||
- ✅ Migrated to: EntityAdapter.getWeapon()
|
||||
- [x] **`routes/database/characters/[id]/+page.server.ts`** - Uses get from api/core
|
||||
- ✅ Migrated to: EntityAdapter.getCharacter()
|
||||
- [x] **`routes/database/summons/[id]/+page.server.ts`** - Uses get from api/core
|
||||
- ✅ Migrated to: EntityAdapter.getSummon()
|
||||
|
||||
### Phase 5: Utility & Support Files (4 files) ✅ COMPLETED
|
||||
- [x] **`lib/api.ts`** (2 imports) - Helper for JSON fetching
|
||||
- ✅ Deleted - functionality inlined in about page
|
||||
- [x] **`lib/server/detail/load.ts`** (2 imports) - Server-side detail loading
|
||||
- ✅ Deleted - no longer needed after migrating to EntityAdapter
|
||||
- [x] **`lib/providers/DatabaseProvider.ts`** (1 import) - Uses API_BASE constant
|
||||
- ✅ Updated to import PUBLIC_SIERO_API_URL directly
|
||||
- [x] **`lib/auth/oauth.ts`** (1 import) - Uses FetchLike type
|
||||
- ✅ Updated to use native fetch type
|
||||
|
||||
### Phase 1.5: Page Server Files Using Resources ✅ COMPLETED
|
||||
- [x] **`routes/teams/explore/+page.server.ts`** - Uses parties resource
|
||||
- ✅ Updated to use partyAdapter.list() directly
|
||||
- [x] **`routes/[username]/+page.server.ts`** - Uses users resource
|
||||
- ✅ Updated to use userAdapter.getProfile() and getFavorites()
|
||||
- [x] **`lib/api/resources/users.ts`** - User resource facade
|
||||
- ✅ Deleted - functionality moved to UserAdapter (created new adapter)
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
### Key Changes Per File Type
|
||||
|
||||
#### For API Resources:
|
||||
```typescript
|
||||
// Before
|
||||
import { buildUrl, get } from '$lib/api/core'
|
||||
const url = buildUrl('/parties')
|
||||
const res = await fetch(url)
|
||||
|
||||
// After
|
||||
import { partyAdapter } from '$lib/api/adapters'
|
||||
const party = await partyAdapter.getByShortcode(shortcode)
|
||||
```
|
||||
|
||||
#### For Services:
|
||||
```typescript
|
||||
// Before
|
||||
constructor(private fetch: FetchLike) {}
|
||||
|
||||
// After
|
||||
constructor(private adapter: PartyAdapter) {}
|
||||
```
|
||||
|
||||
#### For API Routes:
|
||||
```typescript
|
||||
// Before
|
||||
const response = await fetch(buildUrl(`/parties/${id}`))
|
||||
|
||||
// After
|
||||
const party = await partyAdapter.getByShortcode(id)
|
||||
return json(party)
|
||||
```
|
||||
|
||||
#### For Page Server Components:
|
||||
```typescript
|
||||
// Before
|
||||
import { get } from '$lib/api/core'
|
||||
const character = await get(fetch, `/characters/${id}`)
|
||||
|
||||
// After
|
||||
import { entityAdapter } from '$lib/api/adapters'
|
||||
const character = await entityAdapter.getCharacter(id)
|
||||
```
|
||||
|
||||
## Benefits
|
||||
1. **Type Safety**: Adapters provide strong typing for all operations
|
||||
2. **Consistency**: Unified API across all resource types
|
||||
3. **Error Handling**: Centralized error handling with proper types
|
||||
4. **Caching**: Built-in caching with TTL support
|
||||
5. **Transformation**: Automatic snake_case/camelCase conversion
|
||||
6. **Testing**: All adapters have comprehensive test coverage
|
||||
|
||||
## Execution Order
|
||||
1. Start with Phase 1 (API Resources) as they're dependencies
|
||||
2. Move to Phase 2 (Services)
|
||||
3. Tackle Phase 3 (API Routes) in batches by resource type
|
||||
4. Complete Phase 4 (Page Server)
|
||||
5. Finish with Phase 5 (Utility files)
|
||||
|
||||
This migration will be done incrementally, ensuring each phase is complete and tested before moving to the next.
|
||||
1397
docs/api-adapter-architecture.md
Normal file
1397
docs/api-adapter-architecture.md
Normal file
File diff suppressed because it is too large
Load diff
211
docs/api-refactor-plan.md
Normal file
211
docs/api-refactor-plan.md
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
# API Refactoring Plan: Server-Side Only Architecture
|
||||
|
||||
## Executive Summary
|
||||
This document outlines the plan to refactor all Rails API calls in the hensei-svelte application to be server-side only, resolving authentication issues and improving security.
|
||||
|
||||
## Current Architecture Analysis
|
||||
|
||||
### API Layer (`/lib/api/`)
|
||||
- **`core.ts`**: Basic fetch wrappers that accept a `FetchLike` function
|
||||
- **`resources/`**: API endpoint functions (parties, grid, search, etc.)
|
||||
- Functions accept `fetch` as a parameter to work both client and server-side
|
||||
|
||||
### Service Layer (`/lib/services/`)
|
||||
- `PartyService`, `GridService`, `ConflictService`
|
||||
- Services accept `fetch` in constructor and use API resources
|
||||
- Currently used both client-side and server-side
|
||||
|
||||
### Authentication
|
||||
- `hooks.server.ts` has `handleFetch` that adds Bearer token to server-side requests
|
||||
- Client-side calls don't have access to the httpOnly auth token
|
||||
- Edit keys for anonymous users are stored in localStorage (client-side)
|
||||
- This is causing the 401 Unauthorized errors
|
||||
|
||||
## Problems with Current Approach
|
||||
|
||||
1. **Direct client API calls bypass authentication** - The `/teams/new/+page.svelte` directly imports and uses `gridApi` functions
|
||||
2. **Security issue** - Client shouldn't directly call backend API
|
||||
3. **Inconsistent authentication** - Only server-side fetch has the Bearer token
|
||||
4. **Edit keys are client-side only** - Server can't access localStorage where edit keys are stored
|
||||
|
||||
## Proposed Solution: Server-Side API Proxy Routes
|
||||
|
||||
### Step 1: Create Generic API Proxy Routes
|
||||
|
||||
Create server endpoints that mirror the Rails API structure:
|
||||
|
||||
#### `/src/routes/api/parties/+server.ts`
|
||||
- POST: Create new party
|
||||
- Handles both authenticated and anonymous users
|
||||
|
||||
#### `/src/routes/api/parties/[id]/+server.ts`
|
||||
- PUT: Update party details
|
||||
- DELETE: Delete party
|
||||
- Validates edit permissions (authenticated user or edit key)
|
||||
|
||||
#### `/src/routes/api/parties/[id]/weapons/+server.ts`
|
||||
- POST: Add weapon to party
|
||||
- PUT: Update weapon in party
|
||||
- DELETE: Remove weapon from party
|
||||
|
||||
#### `/src/routes/api/parties/[id]/summons/+server.ts`
|
||||
- POST: Add summon to party
|
||||
- PUT: Update summon in party
|
||||
- DELETE: Remove summon from party
|
||||
|
||||
#### `/src/routes/api/parties/[id]/characters/+server.ts`
|
||||
- POST: Add character to party
|
||||
- PUT: Update character in party
|
||||
- DELETE: Remove character from party
|
||||
|
||||
### Step 2: Handle Edit Keys Properly
|
||||
|
||||
Since edit keys are in localStorage (client-side), we need to:
|
||||
1. Pass edit key as a header from client to our proxy endpoints
|
||||
2. Server proxy validates and forwards it to Rails API
|
||||
3. Structure: Client → SvelteKit Server (with edit key) → Rails API
|
||||
|
||||
Example flow:
|
||||
|
||||
```javascript
|
||||
// Client-side
|
||||
const editKey = localStorage.getItem(`edit_key_${party.shortcode}`)
|
||||
await fetch('/api/parties/123/weapons', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-Edit-Key': editKey, // Pass to our server
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({...})
|
||||
})
|
||||
|
||||
// Server-side proxy
|
||||
export async function POST({ request, params, fetch, locals }) {
|
||||
const editKey = request.headers.get('X-Edit-Key')
|
||||
const body = await request.json()
|
||||
|
||||
// Server's fetch automatically adds Bearer token via handleFetch
|
||||
const response = await fetch(`${API_BASE}/weapons`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(editKey ? { 'X-Edit-Key': editKey } : {})
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
|
||||
return response
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: Create a Unified API Client
|
||||
|
||||
Create `/src/lib/api/client.ts` that:
|
||||
- Works only on client-side
|
||||
- Automatically includes edit keys from localStorage
|
||||
- Calls our SvelteKit proxy endpoints (not Rails directly)
|
||||
- Can be used in both `/teams/new` and `/teams/[shortcode]`
|
||||
|
||||
```typescript
|
||||
export class APIClient {
|
||||
private getEditKey(partyId: string): string | null {
|
||||
if (typeof window === 'undefined') return null
|
||||
return localStorage.getItem(`edit_key_${partyId}`)
|
||||
}
|
||||
|
||||
async addWeapon(partyId: string, weaponId: string, position: number, options?: any) {
|
||||
const editKey = this.getEditKey(partyId)
|
||||
const response = await fetch(`/api/parties/${partyId}/weapons`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(editKey ? { 'X-Edit-Key': editKey } : {})
|
||||
},
|
||||
body: JSON.stringify({ weaponId, position, ...options })
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error(`Failed to add weapon: ${response.statusText}`)
|
||||
return response.json()
|
||||
}
|
||||
|
||||
// Similar methods for other operations...
|
||||
}
|
||||
```
|
||||
|
||||
### Step 4: Update Components
|
||||
|
||||
#### `/routes/teams/new/+page.svelte`
|
||||
- Remove direct `gridApi` and `partiesApi` imports
|
||||
- Use the new `APIClient` class
|
||||
- Edit keys handled automatically by the client
|
||||
|
||||
#### `/routes/teams/[shortcode]/+page.svelte` (via Party component)
|
||||
- Use the same `APIClient` for consistency
|
||||
- Edit keys retrieved from localStorage when needed
|
||||
|
||||
#### Services (`PartyService`, `GridService`)
|
||||
- Keep them server-side only
|
||||
- Used in `+page.server.ts` files
|
||||
- Client components use `APIClient` instead
|
||||
|
||||
### Step 5: Authentication Flow
|
||||
|
||||
#### Authenticated Users
|
||||
1. Client → SvelteKit Server (no edit key needed)
|
||||
2. SvelteKit Server → Rails API (with Bearer token from cookies)
|
||||
|
||||
#### Anonymous Users
|
||||
1. Client → SvelteKit Server (with edit key from localStorage)
|
||||
2. SvelteKit Server → Rails API (with edit key header)
|
||||
|
||||
## Benefits of This Approach
|
||||
|
||||
1. **Single API interface** - Same `APIClient` works for both new and existing parties
|
||||
2. **Proper authentication** - Server-side requests include Bearer token
|
||||
3. **Edit key support** - Anonymous users can still edit their parties
|
||||
4. **Security** - Rails API never exposed to client
|
||||
5. **Reusability** - Same code paths for `/teams/new` and `/teams/[shortcode]`
|
||||
6. **Progressive enhancement** - Can still use form actions where appropriate
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. Create the API proxy routes in `/src/routes/api/`
|
||||
2. Create the `APIClient` class
|
||||
3. Update `/routes/teams/new/+page.svelte` to use `APIClient`
|
||||
4. Update Party component to use `APIClient` for grid operations
|
||||
5. Test both authenticated and anonymous user flows
|
||||
6. Remove direct API imports from client components
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
### Authenticated User Flow
|
||||
- [ ] Can create new party
|
||||
- [ ] Can add weapons/summons/characters to new party
|
||||
- [ ] Can edit existing party they own
|
||||
- [ ] Cannot edit party they don't own
|
||||
|
||||
### Anonymous User Flow
|
||||
- [ ] Can create new party (receives edit key)
|
||||
- [ ] Can add items to party using edit key
|
||||
- [ ] Can edit party after page refresh (edit key persists)
|
||||
- [ ] Cannot edit without valid edit key
|
||||
|
||||
### Error Handling
|
||||
- [ ] 401 errors properly handled
|
||||
- [ ] Network errors display user-friendly messages
|
||||
- [ ] Invalid data errors show validation messages
|
||||
|
||||
## Migration Path
|
||||
|
||||
This refactor can be done incrementally:
|
||||
1. Start with new proxy routes (doesn't break existing code)
|
||||
2. Update one component at a time to use new API client
|
||||
3. Gradually remove direct API imports
|
||||
4. Finally remove unused code
|
||||
|
||||
## Notes
|
||||
|
||||
- The `account` cookie is httpOnly for security, which is why we need server-side proxy
|
||||
- Edit keys must be passed from client since they're in localStorage
|
||||
- All Rails API endpoints should remain unchanged
|
||||
- This architecture follows SvelteKit best practices for API integration
|
||||
240
docs/architecture-plan.md
Normal file
240
docs/architecture-plan.md
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
# Clean Architecture Plan: Type-Safe Data Flow with Single Source of Truth
|
||||
|
||||
## Analysis Summary
|
||||
|
||||
After examining the Rails blueprints and current implementation, I've identified the key issues:
|
||||
|
||||
1. **Type Redundancy**: We have multiple types for the same entities (Party vs PartyView, GridWeapon vs GridWeaponItemView)
|
||||
2. **Inconsistent Naming**: The API uses `object` to refer to nested entities (weapon, character, summon)
|
||||
3. **Complex Validation**: The parseParty function does too much transformation and validation
|
||||
4. **Hydration Issues**: Server and client compute different values due to timing and data availability
|
||||
|
||||
## Proposed Architecture
|
||||
|
||||
### 1. Single Source of Truth for Types
|
||||
|
||||
Create clean, single type definitions based on the Rails blueprints:
|
||||
|
||||
```typescript
|
||||
// Core entities (from blueprints)
|
||||
interface Weapon {
|
||||
id: string
|
||||
granblueId: string
|
||||
name: LocalizedName
|
||||
element: number
|
||||
proficiency: number
|
||||
rarity: number
|
||||
// ... all fields from WeaponBlueprint
|
||||
}
|
||||
|
||||
interface Character {
|
||||
id: string
|
||||
granblueId: string
|
||||
name: LocalizedName
|
||||
element: number
|
||||
rarity: number
|
||||
// ... all fields from CharacterBlueprint
|
||||
}
|
||||
|
||||
interface Summon {
|
||||
id: string
|
||||
granblueId: string
|
||||
name: LocalizedName
|
||||
element: number
|
||||
rarity: number
|
||||
// ... all fields from SummonBlueprint
|
||||
}
|
||||
|
||||
// Grid items (relationships)
|
||||
interface GridWeapon {
|
||||
id: string
|
||||
position: number
|
||||
mainhand?: boolean
|
||||
uncapLevel?: number
|
||||
transcendenceStep?: number
|
||||
element?: number
|
||||
weapon: Weapon // Named properly, not "object"
|
||||
weaponKeys?: WeaponKey[]
|
||||
// ... fields from GridWeaponBlueprint
|
||||
}
|
||||
|
||||
interface GridCharacter {
|
||||
id: string
|
||||
position: number
|
||||
uncapLevel?: number
|
||||
perpetuity?: boolean
|
||||
transcendenceStep?: number
|
||||
character: Character // Named properly, not "object"
|
||||
awakening?: Awakening
|
||||
// ... fields from GridCharacterBlueprint
|
||||
}
|
||||
|
||||
interface GridSummon {
|
||||
id: string
|
||||
position: number
|
||||
main?: boolean
|
||||
friend?: boolean
|
||||
quickSummon?: boolean
|
||||
uncapLevel?: number
|
||||
summon: Summon // Named properly, not "object"
|
||||
// ... fields from GridSummonBlueprint
|
||||
}
|
||||
|
||||
interface Party {
|
||||
id: string
|
||||
shortcode: string
|
||||
name?: string
|
||||
description?: string
|
||||
weapons: GridWeapon[]
|
||||
characters: GridCharacter[]
|
||||
summons: GridSummon[]
|
||||
job?: Job
|
||||
raid?: Raid
|
||||
// ... all fields from PartyBlueprint
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Automatic Case Transformation Layer
|
||||
|
||||
Create a type-safe API client that handles transformations automatically:
|
||||
|
||||
```typescript
|
||||
// api/client.ts
|
||||
class ApiClient {
|
||||
private transformResponse<T>(data: any): T {
|
||||
// Transform snake_case to camelCase
|
||||
const camelCased = snakeToCamel(data)
|
||||
|
||||
// Rename "object" to proper entity names
|
||||
if (camelCased.weapons) {
|
||||
camelCased.weapons = camelCased.weapons.map(w => ({
|
||||
...w,
|
||||
weapon: w.object,
|
||||
object: undefined
|
||||
}))
|
||||
}
|
||||
if (camelCased.characters) {
|
||||
camelCased.characters = camelCased.characters.map(c => ({
|
||||
...c,
|
||||
character: c.object,
|
||||
object: undefined
|
||||
}))
|
||||
}
|
||||
if (camelCased.summons) {
|
||||
camelCased.summons = camelCased.summons.map(s => ({
|
||||
...s,
|
||||
summon: s.object,
|
||||
object: undefined
|
||||
}))
|
||||
}
|
||||
|
||||
return camelCased as T
|
||||
}
|
||||
|
||||
private transformRequest<T>(data: T): any {
|
||||
// Transform camelCase to snake_case
|
||||
// Rename entity names back to "object" for API
|
||||
const prepared = {
|
||||
...data,
|
||||
weapons: data.weapons?.map(w => ({
|
||||
...w,
|
||||
object: w.weapon,
|
||||
weapon: undefined
|
||||
})),
|
||||
// Similar for characters and summons
|
||||
}
|
||||
|
||||
return camelToSnake(prepared)
|
||||
}
|
||||
|
||||
async get<T>(path: string): Promise<T> {
|
||||
const response = await fetch(path)
|
||||
const data = await response.json()
|
||||
return this.transformResponse<T>(data)
|
||||
}
|
||||
|
||||
async post<T>(path: string, body: any): Promise<T> {
|
||||
const response = await fetch(path, {
|
||||
body: JSON.stringify(this.transformRequest(body))
|
||||
})
|
||||
const data = await response.json()
|
||||
return this.transformResponse<T>(data)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Service Layer
|
||||
|
||||
Create clean service interfaces that return properly typed data:
|
||||
|
||||
```typescript
|
||||
// services/party.service.ts
|
||||
export class PartyService {
|
||||
constructor(private client: ApiClient) {}
|
||||
|
||||
async getByShortcode(shortcode: string): Promise<Party> {
|
||||
// Client handles all transformations
|
||||
return this.client.get<Party>(`/parties/${shortcode}`)
|
||||
}
|
||||
|
||||
async update(id: string, updates: Partial<Party>): Promise<Party> {
|
||||
return this.client.put<Party>(`/parties/${id}`, updates)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Component Updates
|
||||
|
||||
Components use the clean, properly typed interfaces:
|
||||
|
||||
```typescript
|
||||
// Party.svelte
|
||||
interface Props {
|
||||
initial: Party // Single Party type, no confusion
|
||||
}
|
||||
|
||||
// WeaponUnit.svelte
|
||||
interface Props {
|
||||
item: GridWeapon // Properly typed with weapon property
|
||||
}
|
||||
|
||||
// Access data cleanly:
|
||||
const imageUrl = item.weapon.granblueId
|
||||
const name = item.weapon.name
|
||||
```
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. **Create new type definitions** in `/src/lib/types/`:
|
||||
- `entities.ts` - Core entities (Weapon, Character, Summon, etc.)
|
||||
- `grid.ts` - Grid items (GridWeapon, GridCharacter, GridSummon)
|
||||
- `party.ts` - Party and related types
|
||||
|
||||
2. **Update API client** in `/src/lib/api/`:
|
||||
- Add transformation logic to handle `object` → entity name mapping
|
||||
- Keep snake_case/camelCase transformation
|
||||
- Make it type-safe with generics
|
||||
|
||||
3. **Simplify parseParty**:
|
||||
- Remove validation schemas
|
||||
- Just call the API client's transform method
|
||||
- Trust the API data structure
|
||||
|
||||
4. **Update components**:
|
||||
- Use the new single types everywhere
|
||||
- Access `item.weapon.granblueId` instead of `item.object.granblueId`
|
||||
- Remove all the `as any` casts
|
||||
|
||||
5. **Remove redundant types**:
|
||||
- Delete PartyView, GridWeaponItemView, etc.
|
||||
- Use only the new clean types
|
||||
|
||||
## Benefits
|
||||
|
||||
✅ **Single source of truth** - One type per entity, no confusion
|
||||
✅ **Type safety** - Full TypeScript benefits with proper types
|
||||
✅ **Clean property names** - `weapon`, `character`, `summon` instead of `object`
|
||||
✅ **Automatic transformations** - Handle case conversion in one place
|
||||
✅ **No hydration issues** - Consistent data structure everywhere
|
||||
✅ **Maintainable** - Clear separation of concerns
|
||||
✅ **Future-proof** - Easy to add new entities or fields
|
||||
124
docs/detail-pages-refactor.md
Normal file
124
docs/detail-pages-refactor.md
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
# Database Detail Pages Refactor Plan (SvelteKit-centric)
|
||||
|
||||
This plan refactors the database detail pages to be modular, reusable, and easy to port across characters, weapons, and summons. It leans into SvelteKit features (server `load`, form `actions`, and `use:enhance`) and Svelte composition (slots, small components, and lightweight state helpers).
|
||||
|
||||
## Goals
|
||||
- Consistent scaffold for detail pages (header, edit toolbar, sections, feedback).
|
||||
- Encapsulated edit-mode state + mapping to/from API payloads.
|
||||
- Clean separation between server concerns (loading, saving, validation) and client UI.
|
||||
- Reusable section components that can be shared or adapted per resource.
|
||||
- Smooth path to port the architecture to weapons and summons.
|
||||
|
||||
## Architecture Overview
|
||||
- Client
|
||||
- `DetailScaffold.svelte`: Common header, edit/save/cancel controls, messages, and section slots.
|
||||
- `createEditForm.ts`: Small factory returning state + helpers for edit lifecycle.
|
||||
- Initializes `editData` from the resource model via a schema mapping.
|
||||
- Exposes `editMode`, `toggleEdit`, `reset`, `set`, `get`, and `submit` glue for forms.
|
||||
- `image.ts`: `getResourceImage(type, granblue_id)` centralizing image paths and fallbacks.
|
||||
- Section components per resource (e.g., `CharacterMetadataSection.svelte`, shared `StatsSection` when possible).
|
||||
- Server
|
||||
- Shared loader helpers (e.g., `lib/server/detail/load.ts`) to fetch a resource detail and normalize to a client-facing model.
|
||||
- Form actions in `+page.server.ts` for save (`actions.save`) with validation (optionally Zod) and proper error handling.
|
||||
- Progressive enhancement via `use:enhance` so the UI stays responsive without losing SSR form semantics.
|
||||
- Schema-driven mapping
|
||||
- Per resource (`characters/schema.ts`, `weapons/schema.ts`, `summons/schema.ts`) define:
|
||||
- `toEditData(model)`: API model -> UI edit state.
|
||||
- `toPayload(editData)`: UI edit state -> API payload.
|
||||
- Optional field metadata (labels, formatter hooks) for low-ceremony sections.
|
||||
|
||||
## File Structure (proposed)
|
||||
- `src/lib/features/database/detail/`
|
||||
- `DetailScaffold.svelte` (header + actions + slots)
|
||||
- `createEditForm.ts` (state helper)
|
||||
- `image.ts` (resource image helper)
|
||||
- `api.ts` (client-side action helpers if needed)
|
||||
- `src/lib/features/database/characters/`
|
||||
- `schema.ts`
|
||||
- `sections/`
|
||||
- `CharacterMetadataSection.svelte`
|
||||
- `CharacterUncapSection.svelte`
|
||||
- `CharacterTaxonomySection.svelte`
|
||||
- `CharacterStatsSection.svelte`
|
||||
- `HPStatsSubsection.svelte`
|
||||
- `ATKStatsSubsection.svelte`
|
||||
- Similar folders for `weapons` and `summons` as we port.
|
||||
|
||||
## Form Actions and Loaders
|
||||
- Loader strategy
|
||||
- Current route `+page.ts`/`+page.server.ts` fetches the detailed entity and returns a normalized `model`.
|
||||
- A shared helper `getResourceDetail(resource, id)` can live under `lib/server/detail/load.ts` to reduce duplication across resources.
|
||||
- Save strategy
|
||||
- Define `export const actions = { save: async (event) => { ... } }` in `+page.server.ts`.
|
||||
- Validate incoming form data (Zod/schema), map to API payload via the schema’s `toPayload`, then persist via your backend API.
|
||||
- Return `fail(status, { fieldErrors, message })` on validation errors.
|
||||
- On success, return the updated item; `load` can pick it up or the client can reconcile locally.
|
||||
- Client strategy
|
||||
- Wrap the editable fields in a `<form method="POST">` with `use:enhance`.
|
||||
- For a gradual migration from controlled inputs, keep local `editData` but mirror it into form fields (hidden or direct binds) to submit via actions.
|
||||
- Use action results to show success/error messages in `DetailScaffold`.
|
||||
|
||||
## Phased Tasks
|
||||
|
||||
### Phase 1 — Extract Scaffold + Edit Form Helper
|
||||
- [ ] Create `src/lib/features/database/detail/DetailScaffold.svelte` with slots:
|
||||
- [ ] Header slot or props for resource `type`, `item`, `image`.
|
||||
- [ ] Top-right action area for Edit/Cancel/Save.
|
||||
- [ ] Message area for success/error.
|
||||
- [ ] Default slot for sections.
|
||||
- [ ] Create `src/lib/features/database/detail/createEditForm.ts`:
|
||||
- [ ] Accepts initial `model` and mapping `toEditData`/`toPayload`.
|
||||
- [ ] Returns `editMode`, `editData` (store or bindable object), `toggleEdit`, `reset`, `submit`.
|
||||
- [ ] Resets `editData` when `model` changes.
|
||||
- [ ] Replace inline header/controls in `characters/[id]/+page.svelte` with `DetailScaffold` while preserving behavior.
|
||||
|
||||
### Phase 2 — Extract Character Sections
|
||||
- [ ] `CharacterMetadataSection.svelte` (Rarity, Granblue ID): view/edit with existing utilities.
|
||||
- [ ] `CharacterUncapSection.svelte` (Indicator + FLB/ULB/Transcendence/Special toggles).
|
||||
- [ ] `CharacterTaxonomySection.svelte` (Element, Race1/2, Gender, Proficiency1/2).
|
||||
- [ ] `CharacterStatsSection.svelte` with subcomponents for HP and ATK stats.
|
||||
- [ ] Wire sections to `editMode` and `editData` via props/bindings; keep look-and-feel identical.
|
||||
|
||||
### Phase 3 — Add Schema + Validation + Actions
|
||||
- [ ] `src/lib/features/database/characters/schema.ts`:
|
||||
- [ ] `toEditData(model)` maps current character model to edit state.
|
||||
- [ ] `toPayload(editData)` maps edit state to backend payload.
|
||||
- [ ] Optional Zod schemas for validation.
|
||||
- [ ] Add `+page.server.ts` `actions.save` for characters:
|
||||
- [ ] Parse form data; validate; call backend; return updated item or errors.
|
||||
- [ ] Handle cookies/credentials as needed.
|
||||
- [ ] Wrap editable UI in `<form method="POST" use:enhance>` and handle optimistic UI/disable Save while pending.
|
||||
- [ ] Show validation errors and success states in `DetailScaffold`.
|
||||
|
||||
### Phase 4 — Shared Loaders + Port to Weapons/Summons
|
||||
- [ ] Add `lib/server/detail/load.ts` helpers for shared resource fetching.
|
||||
- [ ] Update characters loader to use shared helper.
|
||||
- [ ] Create `src/lib/features/database/weapons/schema.ts` and sections; adopt the scaffold.
|
||||
- [ ] Create `src/lib/features/database/summons/schema.ts` and sections; adopt the scaffold.
|
||||
- [ ] Implement `+page.server.ts` actions for weapons and summons with validation.
|
||||
|
||||
### Phase 5 — Polish and DX
|
||||
- [ ] `image.ts` helper and replace ad-hoc image path logic.
|
||||
- [ ] Extract `getDisplayName` to a single utility.
|
||||
- [ ] Add unit tests for schema mapping functions (to/from payload).
|
||||
- [ ] Add basic e2e smoke for actions (save success and validation failure).
|
||||
- [ ] Document conventions (where to put schemas, sections, and loaders).
|
||||
|
||||
## Acceptance Criteria
|
||||
- Character detail page renders identically, with no regressions.
|
||||
- Edit mode and saving function via form actions, with graceful errors.
|
||||
- Sections are split into components and trivially testable.
|
||||
- Weapons and summons can be implemented by adding schema + sections and wiring the same scaffold and actions.
|
||||
|
||||
## Risks and Mitigations
|
||||
- Form action migration conflict with controlled inputs:
|
||||
- Mitigate by mirroring `editData` into form fields and using `use:enhance` to reconcile results.
|
||||
- Server validation shape mismatch:
|
||||
- Use Zod schemas that align with backend; log/trace action failures early.
|
||||
- Over-abstracting sections:
|
||||
- Favor small, explicit components; introduce config-driven rendering only where repetition is high and behavior simple.
|
||||
|
||||
---
|
||||
|
||||
If we agree on this plan, start with Phase 1 (scaffold + helper) and wire it into the character page without changing behavior, then proceed section-by-section.
|
||||
|
||||
843
docs/direct-api-architecture-plan-v2.md
Normal file
843
docs/direct-api-architecture-plan-v2.md
Normal file
|
|
@ -0,0 +1,843 @@
|
|||
# Direct API Architecture Plan v2
|
||||
|
||||
## Executive Summary
|
||||
|
||||
This document outlines a comprehensive plan to migrate from proxy-endpoint-based API calls to direct API calls in the Hensei SvelteKit application, with proper token lifecycle management, SSR support, and security hardening.
|
||||
|
||||
### Key Improvements in v2
|
||||
- **Token Bootstrap**: Proper SSR token initialization via hooks and layout
|
||||
- **Refresh Logic**: Single in-flight refresh with proper gating
|
||||
- **Security**: CSP, short TTL tokens, proper CORS configuration
|
||||
- **DX**: TanStack Query integration, proper error handling
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
### Token Flow
|
||||
```
|
||||
1. Initial Load (SSR):
|
||||
hooks.server.ts → Read refresh cookie → Exchange for access token → Pass to client
|
||||
|
||||
2. Client Hydration:
|
||||
+layout.svelte → Receive token from SSR → Initialize auth store
|
||||
|
||||
3. API Calls:
|
||||
Adapter → Check token expiry → Use token or refresh → Retry on 401
|
||||
|
||||
4. Token Refresh:
|
||||
Single in-flight promise → Exchange refresh token → Update store
|
||||
```
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1: Core Authentication Infrastructure
|
||||
|
||||
#### 1.1 Auth Store with Refresh Management
|
||||
`/src/lib/stores/auth.store.ts`:
|
||||
```typescript
|
||||
import { writable, get } from 'svelte/store'
|
||||
import { goto } from '$app/navigation'
|
||||
import { PUBLIC_SIERO_API_URL } from '$env/static/public'
|
||||
|
||||
interface AuthState {
|
||||
accessToken: string | null
|
||||
user: UserInfo | null
|
||||
expiresAt: Date | null
|
||||
refreshPromise: Promise<boolean> | null
|
||||
}
|
||||
|
||||
function createAuthStore() {
|
||||
const { subscribe, set, update } = writable<AuthState>({
|
||||
accessToken: null,
|
||||
user: null,
|
||||
expiresAt: null,
|
||||
refreshPromise: null
|
||||
})
|
||||
|
||||
const API = `${PUBLIC_SIERO_API_URL ?? 'http://localhost:3000'}/api/v1`
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
|
||||
setAuth: (token: string, user: UserInfo, expiresAt: Date) => {
|
||||
set({
|
||||
accessToken: token,
|
||||
user,
|
||||
expiresAt,
|
||||
refreshPromise: null
|
||||
})
|
||||
},
|
||||
|
||||
clearAuth: () => {
|
||||
set({
|
||||
accessToken: null,
|
||||
user: null,
|
||||
expiresAt: null,
|
||||
refreshPromise: null
|
||||
})
|
||||
goto('/login')
|
||||
},
|
||||
|
||||
getToken: () => {
|
||||
const state = get(authStore)
|
||||
|
||||
// Check if token needs refresh (60s buffer)
|
||||
if (state.expiresAt && state.accessToken) {
|
||||
const now = new Date()
|
||||
const buffer = new Date(state.expiresAt.getTime() - 60000)
|
||||
|
||||
if (now >= buffer) {
|
||||
// Token expired or about to expire, trigger refresh
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return state.accessToken
|
||||
},
|
||||
|
||||
async refreshToken(fetcher: typeof fetch = fetch): Promise<boolean> {
|
||||
return update(state => {
|
||||
// If refresh already in progress, return existing promise
|
||||
if (state.refreshPromise) {
|
||||
return state
|
||||
}
|
||||
|
||||
// Create new refresh promise
|
||||
const promise = (async () => {
|
||||
try {
|
||||
const response = await fetcher(`${API}/auth/refresh`, {
|
||||
method: 'POST',
|
||||
credentials: 'include'
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
this.clearAuth()
|
||||
return false
|
||||
}
|
||||
|
||||
const { access_token, user, expires_in } = await response.json()
|
||||
const expiresAt = new Date(Date.now() + expires_in * 1000)
|
||||
|
||||
this.setAuth(access_token, user, expiresAt)
|
||||
return true
|
||||
} catch {
|
||||
this.clearAuth()
|
||||
return false
|
||||
} finally {
|
||||
update(s => ({ ...s, refreshPromise: null }))
|
||||
}
|
||||
})()
|
||||
|
||||
return { ...state, refreshPromise: promise }
|
||||
}).refreshPromise
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const authStore = createAuthStore()
|
||||
```
|
||||
|
||||
#### 1.2 Server Hooks for SSR
|
||||
`/src/hooks.server.ts`:
|
||||
```typescript
|
||||
import type { Handle, HandleFetch } from '@sveltejs/kit'
|
||||
import { PRIVATE_SIERO_API_URL } from '$env/static/private'
|
||||
import { REFRESH_COOKIE } from '$lib/auth/cookies'
|
||||
|
||||
const API_BASE = PRIVATE_SIERO_API_URL || 'http://localhost:3000'
|
||||
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
// Initialize locals
|
||||
event.locals.user = null
|
||||
event.locals.accessToken = null
|
||||
event.locals.expiresAt = null
|
||||
|
||||
// Check for refresh token
|
||||
const refreshToken = event.cookies.get(REFRESH_COOKIE)
|
||||
|
||||
if (refreshToken) {
|
||||
try {
|
||||
// Bootstrap session - exchange refresh for access token
|
||||
const response = await fetch(`${API_BASE}/api/v1/auth/bootstrap`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cookie': event.request.headers.get('cookie') ?? ''
|
||||
},
|
||||
credentials: 'include'
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const { access_token, user, expires_in } = await response.json()
|
||||
event.locals.user = user
|
||||
event.locals.accessToken = access_token
|
||||
event.locals.expiresAt = new Date(Date.now() + expires_in * 1000)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Session bootstrap failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Add CSP headers for security
|
||||
const response = await resolve(event)
|
||||
response.headers.set(
|
||||
'Content-Security-Policy',
|
||||
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' http://localhost:3000"
|
||||
)
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
export const handleFetch: HandleFetch = async ({ event, request, fetch }) => {
|
||||
// For SSR fetches to Rails API, attach access token
|
||||
const isApiCall = request.url.startsWith(API_BASE)
|
||||
|
||||
if (isApiCall && event.locals?.accessToken) {
|
||||
const headers = new Headers(request.headers)
|
||||
headers.set('Authorization', `Bearer ${event.locals.accessToken}`)
|
||||
request = new Request(request, {
|
||||
headers,
|
||||
credentials: 'include'
|
||||
})
|
||||
}
|
||||
|
||||
return fetch(request)
|
||||
}
|
||||
```
|
||||
|
||||
#### 1.3 Layout Server Load
|
||||
`/src/routes/+layout.server.ts`:
|
||||
```typescript
|
||||
import type { LayoutServerLoad } from './$types'
|
||||
|
||||
export const load: LayoutServerLoad = async ({ locals }) => {
|
||||
return {
|
||||
user: locals.user,
|
||||
accessToken: locals.accessToken,
|
||||
expiresAt: locals.expiresAt?.toISOString() ?? null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 1.4 Layout Client Hydration
|
||||
`/src/routes/+layout.svelte`:
|
||||
```typescript
|
||||
<script lang="ts">
|
||||
import { authStore } from '$lib/stores/auth.store'
|
||||
import { onMount } from 'svelte'
|
||||
|
||||
export let data
|
||||
|
||||
// Hydrate auth store on client
|
||||
onMount(() => {
|
||||
if (data?.accessToken && data?.user && data?.expiresAt) {
|
||||
authStore.setAuth(
|
||||
data.accessToken,
|
||||
data.user,
|
||||
new Date(data.expiresAt)
|
||||
)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<slot />
|
||||
```
|
||||
|
||||
### Phase 2: Update Base Adapter with Smart Refresh
|
||||
|
||||
#### 2.1 Enhanced Base Adapter
|
||||
`/src/lib/api/adapters/base.adapter.ts`:
|
||||
```typescript
|
||||
import { authStore } from '$lib/stores/auth.store'
|
||||
import { get } from 'svelte/store'
|
||||
import { PUBLIC_SIERO_API_URL } from '$env/static/public'
|
||||
import { normalizeError, AdapterError } from './errors'
|
||||
|
||||
const API_BASE = `${PUBLIC_SIERO_API_URL ?? 'http://localhost:3000'}/api/v1`
|
||||
|
||||
interface RequestOptions extends RequestInit {
|
||||
retry?: boolean
|
||||
timeout?: number
|
||||
params?: Record<string, any>
|
||||
}
|
||||
|
||||
export abstract class BaseAdapter {
|
||||
constructor(protected fetcher?: typeof fetch) {}
|
||||
|
||||
protected async request<T>(
|
||||
path: string,
|
||||
options: RequestOptions = {}
|
||||
): Promise<T> {
|
||||
const { retry = false, timeout = 30000, params, ...init } = options
|
||||
|
||||
// Build URL with params
|
||||
const url = this.buildUrl(path, params)
|
||||
|
||||
// Get current token (checks expiry)
|
||||
let token = authStore.getToken()
|
||||
|
||||
// If token is null (expired), try refresh
|
||||
if (!token && !retry) {
|
||||
const refreshed = await authStore.refreshToken(this.fetcher ?? fetch)
|
||||
if (refreshed) {
|
||||
token = authStore.getToken()
|
||||
}
|
||||
}
|
||||
|
||||
// Use provided fetcher or global fetch
|
||||
const fetcher = this.fetcher ?? fetch
|
||||
|
||||
// Create abort controller for timeout
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeout)
|
||||
|
||||
try {
|
||||
const response = await fetcher(url, {
|
||||
...init,
|
||||
signal: controller.signal,
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { 'Authorization': `Bearer ${token}` } : {}),
|
||||
...(init.headers ?? {})
|
||||
},
|
||||
body: this.prepareBody(init.body)
|
||||
})
|
||||
|
||||
clearTimeout(timeoutId)
|
||||
|
||||
// Handle 401 with single retry
|
||||
if (response.status === 401 && !retry) {
|
||||
const refreshed = await authStore.refreshToken(fetcher)
|
||||
if (refreshed) {
|
||||
return this.request<T>(path, { ...options, retry: true })
|
||||
}
|
||||
authStore.clearAuth()
|
||||
throw new AdapterError('Unauthorized', 401)
|
||||
}
|
||||
|
||||
// Handle other error responses
|
||||
if (!response.ok) {
|
||||
const error = await this.parseErrorResponse(response)
|
||||
throw error
|
||||
}
|
||||
|
||||
// Parse successful response
|
||||
const data = await response.json()
|
||||
return this.transformResponse<T>(data)
|
||||
|
||||
} catch (error: any) {
|
||||
clearTimeout(timeoutId)
|
||||
|
||||
// Handle abort
|
||||
if (error.name === 'AbortError') {
|
||||
throw new AdapterError('Request timeout', 0)
|
||||
}
|
||||
|
||||
// Re-throw adapter errors
|
||||
if (error instanceof AdapterError) {
|
||||
throw error
|
||||
}
|
||||
|
||||
// Normalize other errors
|
||||
throw normalizeError(error)
|
||||
}
|
||||
}
|
||||
|
||||
private buildUrl(path: string, params?: Record<string, any>): string {
|
||||
const url = new URL(`${API_BASE}${path}`)
|
||||
|
||||
if (params) {
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null) {
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach(v => url.searchParams.append(key, String(v)))
|
||||
} else {
|
||||
url.searchParams.set(key, String(value))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
private prepareBody(body: any): BodyInit | null {
|
||||
if (body === null || body === undefined) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (typeof body === 'object' && !(body instanceof FormData)) {
|
||||
return JSON.stringify(this.transformRequest(body))
|
||||
}
|
||||
|
||||
return body as BodyInit
|
||||
}
|
||||
|
||||
private async parseErrorResponse(response: Response): Promise<AdapterError> {
|
||||
try {
|
||||
const data = await response.json()
|
||||
return new AdapterError(
|
||||
data.error || response.statusText,
|
||||
response.status,
|
||||
data.details
|
||||
)
|
||||
} catch {
|
||||
return new AdapterError(response.statusText, response.status)
|
||||
}
|
||||
}
|
||||
|
||||
// Override in subclasses for custom transformations
|
||||
protected transformRequest(data: any): any {
|
||||
// Convert camelCase to snake_case
|
||||
return this.toSnakeCase(data)
|
||||
}
|
||||
|
||||
protected transformResponse<T>(data: any): T {
|
||||
// Convert snake_case to camelCase
|
||||
return this.toCamelCase(data) as T
|
||||
}
|
||||
|
||||
// Helper methods for case conversion
|
||||
private toSnakeCase(obj: any): any {
|
||||
if (obj === null || obj === undefined) return obj
|
||||
if (obj instanceof Date) return obj.toISOString()
|
||||
if (typeof obj !== 'object') return obj
|
||||
if (Array.isArray(obj)) return obj.map(v => this.toSnakeCase(v))
|
||||
|
||||
const converted: any = {}
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
const snakeKey = key.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`)
|
||||
converted[snakeKey] = this.toSnakeCase(value)
|
||||
}
|
||||
return converted
|
||||
}
|
||||
|
||||
private toCamelCase(obj: any): any {
|
||||
if (obj === null || obj === undefined) return obj
|
||||
if (typeof obj !== 'object') return obj
|
||||
if (Array.isArray(obj)) return obj.map(v => this.toCamelCase(v))
|
||||
|
||||
const converted: any = {}
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
const camelKey = key.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase())
|
||||
converted[camelKey] = this.toCamelCase(value)
|
||||
}
|
||||
return converted
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 3: Update Auth Endpoints
|
||||
|
||||
#### 3.1 Login Endpoint
|
||||
`/src/routes/auth/login/+server.ts`:
|
||||
```typescript
|
||||
import { json } from '@sveltejs/kit'
|
||||
import type { RequestHandler } from './$types'
|
||||
import { z } from 'zod'
|
||||
import { setRefreshCookie } from '$lib/auth/cookies'
|
||||
|
||||
const LoginSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(8)
|
||||
})
|
||||
|
||||
export const POST: RequestHandler = async ({ request, cookies, url, fetch }) => {
|
||||
const body = await request.json().catch(() => ({}))
|
||||
const parsed = LoginSchema.safeParse(body)
|
||||
|
||||
if (!parsed.success) {
|
||||
return json({ error: 'Invalid credentials' }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
// Call Rails OAuth endpoint
|
||||
const response = await fetch('http://localhost:3000/oauth/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
...parsed.data,
|
||||
grant_type: 'password',
|
||||
client_id: process.env.OAUTH_CLIENT_ID,
|
||||
client_secret: process.env.OAUTH_CLIENT_SECRET
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
return json({ error: 'Invalid credentials' }, { status: 401 })
|
||||
}
|
||||
|
||||
const oauth = await response.json()
|
||||
|
||||
// Store refresh token in httpOnly cookie
|
||||
setRefreshCookie(cookies, oauth.refresh_token, {
|
||||
secure: url.protocol === 'https:',
|
||||
maxAge: 60 * 60 * 24 * 30 // 30 days
|
||||
})
|
||||
|
||||
// Return access token and user info to client
|
||||
return json({
|
||||
access_token: oauth.access_token,
|
||||
user: oauth.user,
|
||||
expires_in: oauth.expires_in
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Login failed:', error)
|
||||
return json({ error: 'Login failed' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.2 Refresh Endpoint
|
||||
`/src/routes/auth/refresh/+server.ts`:
|
||||
```typescript
|
||||
import { json } from '@sveltejs/kit'
|
||||
import type { RequestHandler } from './$types'
|
||||
import { REFRESH_COOKIE, setRefreshCookie } from '$lib/auth/cookies'
|
||||
|
||||
export const POST: RequestHandler = async ({ cookies, url, fetch }) => {
|
||||
const refreshToken = cookies.get(REFRESH_COOKIE)
|
||||
|
||||
if (!refreshToken) {
|
||||
return json({ error: 'No refresh token' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('http://localhost:3000/oauth/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: refreshToken,
|
||||
client_id: process.env.OAUTH_CLIENT_ID,
|
||||
client_secret: process.env.OAUTH_CLIENT_SECRET
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
// Clear invalid refresh token
|
||||
cookies.delete(REFRESH_COOKIE, { path: '/' })
|
||||
return json({ error: 'Invalid refresh token' }, { status: 401 })
|
||||
}
|
||||
|
||||
const oauth = await response.json()
|
||||
|
||||
// Update refresh token (rotation)
|
||||
if (oauth.refresh_token) {
|
||||
setRefreshCookie(cookies, oauth.refresh_token, {
|
||||
secure: url.protocol === 'https:',
|
||||
maxAge: 60 * 60 * 24 * 30
|
||||
})
|
||||
}
|
||||
|
||||
return json({
|
||||
access_token: oauth.access_token,
|
||||
user: oauth.user,
|
||||
expires_in: oauth.expires_in
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Refresh failed:', error)
|
||||
return json({ error: 'Refresh failed' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.3 Logout Endpoint
|
||||
`/src/routes/auth/logout/+server.ts`:
|
||||
```typescript
|
||||
import { json } from '@sveltejs/kit'
|
||||
import type { RequestHandler } from './$types'
|
||||
import { REFRESH_COOKIE } from '$lib/auth/cookies'
|
||||
|
||||
export const POST: RequestHandler = async ({ cookies, fetch }) => {
|
||||
const refreshToken = cookies.get(REFRESH_COOKIE)
|
||||
|
||||
// Revoke token on Rails side
|
||||
if (refreshToken) {
|
||||
try {
|
||||
await fetch('http://localhost:3000/oauth/revoke', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
token: refreshToken,
|
||||
token_type_hint: 'refresh_token',
|
||||
client_id: process.env.OAUTH_CLIENT_ID,
|
||||
client_secret: process.env.OAUTH_CLIENT_SECRET
|
||||
})
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Token revocation failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Clear cookie regardless
|
||||
cookies.delete(REFRESH_COOKIE, { path: '/' })
|
||||
|
||||
return json({ success: true })
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 4: Fix Grid Adapter
|
||||
|
||||
#### 4.1 Corrected Grid Adapter Methods
|
||||
`/src/lib/api/adapters/grid.adapter.ts` (key fixes):
|
||||
```typescript
|
||||
// Fix DELETE to include ID
|
||||
async deleteWeapon(id: string): Promise<void> {
|
||||
return this.request<void>(`/grid_weapons/${id}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
}
|
||||
|
||||
// Fix position update URL
|
||||
async updateWeaponPosition(params: UpdatePositionParams): Promise<GridWeapon> {
|
||||
const { id, position, container } = params
|
||||
return this.request<GridWeapon>(`/grid_weapons/${id}/update_position`, {
|
||||
method: 'POST',
|
||||
body: { position, container }
|
||||
})
|
||||
}
|
||||
|
||||
// Fix swap URL (no partyId in path)
|
||||
async swapWeapons(params: SwapPositionsParams): Promise<{
|
||||
source: GridWeapon
|
||||
target: GridWeapon
|
||||
}> {
|
||||
return this.request('/grid_weapons/swap', {
|
||||
method: 'POST',
|
||||
body: params
|
||||
})
|
||||
}
|
||||
|
||||
// Apply same patterns to characters and summons...
|
||||
```
|
||||
|
||||
### Phase 5: Rails Configuration
|
||||
|
||||
#### 5.1 Update CORS Configuration
|
||||
`config/initializers/cors.rb`:
|
||||
```ruby
|
||||
Rails.application.config.middleware.insert_before 0, Rack::Cors do
|
||||
allow do
|
||||
origins(
|
||||
Rails.env.production? ?
|
||||
['https://app.hensei.dev', 'https://hensei.dev'] :
|
||||
['http://localhost:5173', 'http://localhost:5174', 'http://127.0.0.1:5173']
|
||||
)
|
||||
|
||||
resource '/api/*',
|
||||
headers: %w[Accept Authorization Content-Type X-Edit-Key],
|
||||
expose: %w[X-RateLimit-Limit X-RateLimit-Remaining X-RateLimit-Reset],
|
||||
methods: %i[get post put patch delete options head],
|
||||
credentials: true,
|
||||
max_age: 86400
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
#### 5.2 Add Bootstrap Endpoint
|
||||
`app/controllers/api/v1/auth_controller.rb`:
|
||||
```ruby
|
||||
def bootstrap
|
||||
# This is called by hooks.server.ts with refresh token in cookie
|
||||
refresh_token = cookies[:refresh_token]
|
||||
|
||||
if refresh_token.blank?
|
||||
render json: { error: 'No refresh token' }, status: :unauthorized
|
||||
return
|
||||
end
|
||||
|
||||
# Use Doorkeeper to validate and exchange
|
||||
token = Doorkeeper::AccessToken.by_refresh_token(refresh_token)
|
||||
|
||||
if token.nil? || token.revoked?
|
||||
render json: { error: 'Invalid refresh token' }, status: :unauthorized
|
||||
return
|
||||
end
|
||||
|
||||
# Create new access token
|
||||
new_token = Doorkeeper::AccessToken.create!(
|
||||
application: token.application,
|
||||
resource_owner_id: token.resource_owner_id,
|
||||
scopes: token.scopes,
|
||||
expires_in: 900, # 15 minutes
|
||||
use_refresh_token: false
|
||||
)
|
||||
|
||||
user = User.find(new_token.resource_owner_id)
|
||||
|
||||
render json: {
|
||||
access_token: new_token.token,
|
||||
user: UserBlueprint.render_as_hash(user, view: :auth),
|
||||
expires_in: new_token.expires_in
|
||||
}
|
||||
end
|
||||
```
|
||||
|
||||
### Phase 6: Add TanStack Query
|
||||
|
||||
#### 6.1 Install Dependencies
|
||||
```bash
|
||||
pnpm add @tanstack/svelte-query
|
||||
```
|
||||
|
||||
#### 6.2 Setup Query Client
|
||||
`/src/lib/query/client.ts`:
|
||||
```typescript
|
||||
import { QueryClient } from '@tanstack/svelte-query'
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
cacheTime: 1000 * 60 * 10, // 10 minutes
|
||||
retry: (failureCount, error: any) => {
|
||||
if (error?.status === 401) return false
|
||||
return failureCount < 3
|
||||
},
|
||||
refetchOnWindowFocus: false
|
||||
},
|
||||
mutations: {
|
||||
retry: false
|
||||
}
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
#### 6.3 Use in Components
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import { createQuery, createMutation } from '@tanstack/svelte-query'
|
||||
import { partyAdapter } from '$lib/api/adapters'
|
||||
|
||||
const partyQuery = createQuery({
|
||||
queryKey: ['party', shortcode],
|
||||
queryFn: () => partyAdapter.getByShortcode(shortcode),
|
||||
enabled: !!shortcode
|
||||
})
|
||||
|
||||
const updateMutation = createMutation({
|
||||
mutationFn: (data) => partyAdapter.update(shortcode, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(['party', shortcode])
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
{#if $partyQuery.isLoading}
|
||||
<Loading />
|
||||
{:else if $partyQuery.error}
|
||||
<Error message={$partyQuery.error.message} />
|
||||
{:else if $partyQuery.data}
|
||||
<Party data={$partyQuery.data} />
|
||||
{/if}
|
||||
```
|
||||
|
||||
## Migration Timeline
|
||||
|
||||
### Day 0: Preparation
|
||||
- [ ] Backup current state
|
||||
- [ ] Review Rails CORS configuration
|
||||
- [ ] Setup feature flags
|
||||
|
||||
### Day 1: Core Authentication
|
||||
- [ ] Implement auth store with refresh logic
|
||||
- [ ] Add hooks.server.ts and handleFetch
|
||||
- [ ] Update layout server/client
|
||||
- [ ] Create auth endpoints (login, refresh, logout)
|
||||
- [ ] Test SSR token bootstrap
|
||||
|
||||
### Day 2: Adapter Updates
|
||||
- [ ] Update BaseAdapter with smart refresh
|
||||
- [ ] Fix GridAdapter URLs and methods
|
||||
- [ ] Update adapter configuration
|
||||
- [ ] Add TanStack Query
|
||||
- [ ] Test with one adapter (PartyAdapter)
|
||||
|
||||
### Day 3: Complete Migration
|
||||
- [ ] Update all remaining adapters
|
||||
- [ ] Update all components to use adapters
|
||||
- [ ] Remove all proxy endpoints
|
||||
- [ ] Test all operations
|
||||
|
||||
### Day 4: Hardening & Cleanup
|
||||
- [ ] Add CSP headers
|
||||
- [ ] Configure token TTLs
|
||||
- [ ] Add request timeouts
|
||||
- [ ] Performance testing
|
||||
- [ ] Documentation
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Tests
|
||||
```typescript
|
||||
// Test auth store refresh logic
|
||||
test('refreshes token when expired', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
access_token: 'new_token',
|
||||
expires_in: 900
|
||||
})
|
||||
})
|
||||
|
||||
authStore.setAuth('old_token', user, new Date(Date.now() - 1000))
|
||||
const token = await authStore.getToken()
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/auth/refresh'),
|
||||
expect.objectContaining({ method: 'POST' })
|
||||
)
|
||||
})
|
||||
```
|
||||
|
||||
### E2E Tests (Playwright)
|
||||
```typescript
|
||||
test('grid operations work with auth', async ({ page }) => {
|
||||
// Login
|
||||
await page.goto('/login')
|
||||
await page.fill('[name=email]', 'test@example.com')
|
||||
await page.fill('[name=password]', 'password')
|
||||
await page.click('button[type=submit]')
|
||||
|
||||
// Navigate to party
|
||||
await page.goto('/teams/test-party')
|
||||
|
||||
// Test grid operations
|
||||
await page.click('[data-testid=add-weapon]')
|
||||
await expect(page.locator('.weapon-grid')).toContainText('New Weapon')
|
||||
})
|
||||
```
|
||||
|
||||
## Security Checklist
|
||||
|
||||
- [ ] **CSP Headers**: Strict Content Security Policy
|
||||
- [ ] **Token TTL**: 15-minute access tokens
|
||||
- [ ] **Refresh Rotation**: New refresh token on each use
|
||||
- [ ] **Revocation**: Proper logout with token revocation
|
||||
- [ ] **CORS**: Explicit origins, no wildcards
|
||||
- [ ] **HTTPS**: Secure cookies in production
|
||||
- [ ] **XSS Protection**: No token in localStorage
|
||||
- [ ] **CSRF**: Not needed with Bearer tokens
|
||||
|
||||
## Success Metrics
|
||||
|
||||
1. **No 401/404 Errors**: All API calls succeed
|
||||
2. **SSR Works**: Server-rendered pages have data
|
||||
3. **Fast Refresh**: < 100ms token refresh
|
||||
4. **No Token Leaks**: Tokens not in localStorage/sessionStorage
|
||||
5. **Performance**: 20% reduction in API latency
|
||||
|
||||
## Rollback Plan
|
||||
|
||||
If issues arise:
|
||||
1. **Feature Flag**: Toggle `USE_DIRECT_API` env var
|
||||
2. **Restore Proxies**: Git revert removal commit
|
||||
3. **Switch Adapters**: Conditional logic in config.ts
|
||||
4. **Monitor**: Check error rates in Sentry
|
||||
|
||||
---
|
||||
|
||||
*Document Version: 2.0*
|
||||
*Updated with comprehensive token lifecycle, SSR support, and security improvements*
|
||||
*Ready for Production Implementation*
|
||||
340
docs/direct-api-architecture-plan.md
Normal file
340
docs/direct-api-architecture-plan.md
Normal file
|
|
@ -0,0 +1,340 @@
|
|||
# Direct API Architecture Plan
|
||||
|
||||
## Executive Summary
|
||||
|
||||
This document outlines a comprehensive plan to migrate from proxy-endpoint-based API calls to direct API calls in the Hensei SvelteKit application. This change will resolve current authentication and routing errors while simplifying the codebase and improving performance.
|
||||
|
||||
### Key Problems Being Solved
|
||||
1. **404 Errors**: Grid operations failing due to missing/incorrect proxy endpoints
|
||||
2. **401 Authentication Errors**: Cookie-based auth not working properly through proxies
|
||||
3. **Complexity**: Maintaining duplicate routing logic in both adapters and proxy endpoints
|
||||
4. **Performance**: Extra network hop through proxy adds latency
|
||||
5. **Inconsistency**: Some operations use SSR, some use proxies, some try direct calls
|
||||
|
||||
### Solution
|
||||
Implement direct API calls from the browser to the Rails API using Bearer token authentication, which is the standard approach for modern SvelteKit applications.
|
||||
|
||||
## Current Architecture Analysis
|
||||
|
||||
### What We Have Now
|
||||
|
||||
```
|
||||
Browser → SvelteKit Proxy (/api/*) → Rails API (localhost:3000/api/v1/*)
|
||||
```
|
||||
|
||||
#### Current Authentication Flow
|
||||
1. User logs in via `/auth/login/+server.ts`
|
||||
2. OAuth token received and stored in httpOnly cookies
|
||||
3. Proxy endpoints read cookies and forward to Rails
|
||||
4. Rails uses Doorkeeper OAuth to authenticate via Bearer token
|
||||
|
||||
#### Current Problems
|
||||
- **Proxy Endpoints**: 20+ proxy files in `/src/routes/api/`
|
||||
- **Adapter Configuration**: Uses `/api` in browser, expecting proxies that don't exist
|
||||
- **Grid Operations**: Broken due to missing/incorrect proxy endpoints
|
||||
- **URL Mismatches**: Grid adapter uses wrong URL patterns
|
||||
- **Parameter Wrapping**: Inconsistent parameter structures
|
||||
|
||||
## Proposed Architecture
|
||||
|
||||
### Direct API Calls
|
||||
|
||||
```
|
||||
Browser → Rails API (localhost:3000/api/v1/*)
|
||||
```
|
||||
|
||||
#### New Authentication Flow
|
||||
1. User logs in and receives OAuth token
|
||||
2. Store access token in:
|
||||
- Server-side: httpOnly cookie (for SSR)
|
||||
- Client-side: Memory/store (for browser requests)
|
||||
3. Include Bearer token in Authorization header for all API calls
|
||||
4. Rails authenticates directly via Doorkeeper
|
||||
|
||||
#### CORS Configuration
|
||||
Rails already has proper CORS configuration:
|
||||
```ruby
|
||||
origins %w[localhost:5173 127.0.0.1:5173]
|
||||
credentials: true
|
||||
methods: %i[get post put patch delete options head]
|
||||
```
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1: Update Authentication System
|
||||
|
||||
#### 1.1 Create Token Store
|
||||
Create `/src/lib/stores/auth.store.ts`:
|
||||
```typescript
|
||||
import { writable, get } from 'svelte/store'
|
||||
|
||||
interface AuthState {
|
||||
accessToken: string | null
|
||||
user: UserInfo | null
|
||||
expiresAt: Date | null
|
||||
}
|
||||
|
||||
function createAuthStore() {
|
||||
const { subscribe, set, update } = writable<AuthState>({
|
||||
accessToken: null,
|
||||
user: null,
|
||||
expiresAt: null
|
||||
})
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
setAuth: (token: string, user: UserInfo, expiresAt: Date) => {
|
||||
set({ accessToken: token, user, expiresAt })
|
||||
},
|
||||
clearAuth: () => {
|
||||
set({ accessToken: null, user: null, expiresAt: null })
|
||||
},
|
||||
getToken: () => get(authStore).accessToken
|
||||
}
|
||||
}
|
||||
|
||||
export const authStore = createAuthStore()
|
||||
```
|
||||
|
||||
#### 1.2 Update Login Handler
|
||||
Modify `/src/routes/auth/login/+server.ts`:
|
||||
- Continue setting httpOnly cookies for SSR
|
||||
- Also return access token in response for client storage
|
||||
|
||||
#### 1.3 Update Root Layout
|
||||
Modify `/src/routes/+layout.svelte`:
|
||||
- Initialize auth store from page data
|
||||
- Handle token refresh
|
||||
|
||||
### Phase 2: Update Adapter System
|
||||
|
||||
#### 2.1 Fix Adapter Configuration
|
||||
Update `/src/lib/api/adapters/config.ts`:
|
||||
```typescript
|
||||
export function getApiBaseUrl(): string {
|
||||
// Always use direct API URL
|
||||
const base = PUBLIC_SIERO_API_URL || 'http://localhost:3000'
|
||||
return `${base}/api/v1`
|
||||
}
|
||||
```
|
||||
|
||||
#### 2.2 Update Base Adapter
|
||||
Modify `/src/lib/api/adapters/base.adapter.ts`:
|
||||
```typescript
|
||||
protected async request<T>(
|
||||
path: string,
|
||||
options: RequestOptions = {}
|
||||
): Promise<T> {
|
||||
// Add Bearer token from auth store
|
||||
const token = authStore.getToken()
|
||||
|
||||
const fetchOptions: RequestInit = {
|
||||
...options,
|
||||
credentials: 'include', // Still include for CORS
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { 'Authorization': `Bearer ${token}` } : {}),
|
||||
...(options.headers || {})
|
||||
}
|
||||
}
|
||||
|
||||
// ... rest of implementation
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 3: Fix Grid Adapter Issues
|
||||
|
||||
#### 3.1 Fix DELETE Methods
|
||||
Update `/src/lib/api/adapters/grid.adapter.ts`:
|
||||
```typescript
|
||||
async deleteWeapon(params: { id: string; partyId: string }): Promise<void> {
|
||||
return this.request<void>(`/grid_weapons/${params.id}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
}
|
||||
// Similar for deleteCharacter and deleteSummon
|
||||
```
|
||||
|
||||
#### 3.2 Fix Position Update URLs
|
||||
```typescript
|
||||
async updateWeaponPosition(params: UpdatePositionParams): Promise<GridWeapon> {
|
||||
const { id, position, container } = params
|
||||
return this.request<GridWeapon>(`/grid_weapons/${id}/update_position`, {
|
||||
method: 'POST',
|
||||
body: { position, container }
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.3 Fix Swap URLs
|
||||
```typescript
|
||||
async swapWeapons(params: SwapPositionsParams): Promise<{
|
||||
source: GridWeapon
|
||||
target: GridWeapon
|
||||
}> {
|
||||
return this.request('/grid_weapons/swap', {
|
||||
method: 'POST',
|
||||
body: params
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 4: Remove Proxy Endpoints
|
||||
|
||||
Delete the entire `/src/routes/api/` directory:
|
||||
```bash
|
||||
rm -rf /src/routes/api/
|
||||
```
|
||||
|
||||
### Phase 5: Update Services
|
||||
|
||||
#### 5.1 Party Service
|
||||
Update to pass auth token if needed for SSR:
|
||||
```typescript
|
||||
class PartyService {
|
||||
constructor(private fetch?: typeof window.fetch) {}
|
||||
|
||||
async getByShortcode(shortcode: string): Promise<Party> {
|
||||
// On server, use fetch with cookies
|
||||
// On client, adapter will use auth store
|
||||
return partyAdapter.getByShortcode(shortcode)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 6: Update Components
|
||||
|
||||
#### 6.1 Party Component
|
||||
No changes needed - already uses services correctly
|
||||
|
||||
#### 6.2 Search Components
|
||||
Update to use adapters directly instead of proxy endpoints
|
||||
|
||||
## Files to Change
|
||||
|
||||
### Core Authentication Files
|
||||
1. `/src/lib/stores/auth.store.ts` - **CREATE** - Token storage
|
||||
2. `/src/routes/auth/login/+server.ts` - Return token in response
|
||||
3. `/src/routes/+layout.svelte` - Initialize auth store
|
||||
4. `/src/routes/+layout.server.ts` - Pass token to client
|
||||
|
||||
### Adapter System Files
|
||||
5. `/src/lib/api/adapters/config.ts` - Remove proxy logic
|
||||
6. `/src/lib/api/adapters/base.adapter.ts` - Add Bearer token support
|
||||
7. `/src/lib/api/adapters/grid.adapter.ts` - Fix all URL patterns and methods
|
||||
8. `/src/lib/api/adapters/party.adapter.ts` - Ensure proper auth
|
||||
9. `/src/lib/api/adapters/search.adapter.ts` - Ensure proper auth
|
||||
10. `/src/lib/api/adapters/entity.adapter.ts` - Ensure proper auth
|
||||
11. `/src/lib/api/adapters/user.adapter.ts` - Ensure proper auth
|
||||
|
||||
### Service Files (Minor Updates)
|
||||
12. `/src/lib/services/grid.service.ts` - Already correct
|
||||
13. `/src/lib/services/party.service.ts` - Already correct
|
||||
14. `/src/lib/services/conflict.service.ts` - Already correct
|
||||
|
||||
### Component Files (No Changes)
|
||||
- `/src/lib/components/party/Party.svelte` - Already uses services
|
||||
- All grid components - Already use context correctly
|
||||
|
||||
### Files to DELETE
|
||||
15. `/src/routes/api/` - **DELETE ENTIRE DIRECTORY**
|
||||
|
||||
### SSR Routes (No Changes Needed)
|
||||
- `/src/routes/teams/[id]/+page.server.ts` - Keep as-is
|
||||
- `/src/routes/teams/explore/+page.server.ts` - Keep as-is
|
||||
- All other `+page.server.ts` files - Keep as-is
|
||||
|
||||
## Benefits of This Approach
|
||||
|
||||
### Performance
|
||||
- **Eliminates proxy latency**: Direct calls are faster
|
||||
- **Reduces server load**: No proxy processing
|
||||
- **Better caching**: Browser can cache API responses directly
|
||||
|
||||
### Simplicity
|
||||
- **Less code**: Remove 20+ proxy endpoint files
|
||||
- **Single source of truth**: Adapters handle all API logic
|
||||
- **Standard pattern**: Follows SvelteKit best practices
|
||||
|
||||
### Reliability
|
||||
- **Fixes authentication**: Bearer tokens work consistently
|
||||
- **Fixes routing**: Direct URLs eliminate 404 errors
|
||||
- **Better error handling**: Errors come directly from API
|
||||
|
||||
### Developer Experience
|
||||
- **Easier debugging**: Network tab shows actual API calls
|
||||
- **Less complexity**: No proxy layer to understand
|
||||
- **Industry standard**: What most SvelteKit apps do
|
||||
|
||||
## Trade-offs and Considerations
|
||||
|
||||
### Security Considerations
|
||||
1. **API URL exposed**: Browser can see Rails API URL (acceptable)
|
||||
2. **Token in memory**: XSS vulnerability (mitigated by httpOnly refresh token)
|
||||
3. **CORS required**: Must trust frontend origin (already configured)
|
||||
|
||||
### Migration Risks
|
||||
1. **Breaking change**: All API calls will change
|
||||
2. **Testing required**: Need to test all operations
|
||||
3. **Token management**: Need to handle expiry/refresh
|
||||
|
||||
### Mitigation Strategies
|
||||
1. **Incremental rollout**: Can update adapters one at a time
|
||||
2. **Feature flags**: Can toggle between old/new approach
|
||||
3. **Comprehensive testing**: Test each operation before removing proxies
|
||||
|
||||
## Implementation Timeline
|
||||
|
||||
### Day 1: Authentication System
|
||||
- Create auth store
|
||||
- Update login flow
|
||||
- Test authentication
|
||||
|
||||
### Day 2: Adapter Updates
|
||||
- Update adapter configuration
|
||||
- Add Bearer token support
|
||||
- Fix Grid adapter URLs
|
||||
|
||||
### Day 3: Testing & Cleanup
|
||||
- Test all grid operations
|
||||
- Test search, favorites, etc.
|
||||
- Remove proxy endpoints
|
||||
|
||||
### Day 4: Final Testing
|
||||
- End-to-end testing
|
||||
- Performance testing
|
||||
- Documentation updates
|
||||
|
||||
## Success Criteria
|
||||
|
||||
1. **All grid operations work**: Add, update, delete, move, swap
|
||||
2. **Authentication works**: Login, logout, refresh
|
||||
3. **No 404 errors**: All API calls succeed
|
||||
4. **No 401 errors**: Authentication works consistently
|
||||
5. **Performance improvement**: Measurable latency reduction
|
||||
|
||||
## Conclusion
|
||||
|
||||
This migration to direct API calls will:
|
||||
1. **Solve immediate problems**: Fix broken grid operations
|
||||
2. **Improve architecture**: Align with SvelteKit best practices
|
||||
3. **Reduce complexity**: Remove unnecessary proxy layer
|
||||
4. **Improve performance**: Eliminate proxy latency
|
||||
5. **Enhance maintainability**: Single source of truth for API logic
|
||||
|
||||
The approach is standard for modern SvelteKit applications and is what "9 out of 10 Svelte developers" would implement. It leverages the existing CORS configuration in Rails and uses industry-standard Bearer token authentication.
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. Review and approve this plan
|
||||
2. Create auth store implementation
|
||||
3. Update adapters incrementally
|
||||
4. Test thoroughly
|
||||
5. Remove proxy endpoints
|
||||
6. Deploy with confidence
|
||||
|
||||
---
|
||||
|
||||
*Document created: November 2024*
|
||||
*Author: Claude Assistant*
|
||||
*Status: Ready for Implementation*
|
||||
473
docs/drag-drop-api-prd.md
Normal file
473
docs/drag-drop-api-prd.md
Normal file
|
|
@ -0,0 +1,473 @@
|
|||
# Drag-Drop API Endpoints PRD
|
||||
|
||||
## Overview
|
||||
|
||||
This document outlines the API endpoints required to support drag-and-drop functionality for party grid management in the Hensei application. The frontend has implemented drag-drop interactions, but requires backend endpoints to persist position changes and item swaps.
|
||||
|
||||
## Problem Statement
|
||||
|
||||
### Current State
|
||||
- The API currently only supports add/remove operations for grid items
|
||||
- Position changes require removing and re-adding items
|
||||
- Swapping items requires multiple API calls (remove both, then add both)
|
||||
- This approach is error-prone and creates race conditions
|
||||
- No atomic operations for complex moves
|
||||
|
||||
### User Impact
|
||||
- Drag-drop appears to work visually but doesn't persist
|
||||
- Risk of data loss if operations partially fail
|
||||
- Poor performance with multiple network requests
|
||||
- Inconsistent state between UI and database
|
||||
|
||||
## Proposed Solution
|
||||
|
||||
Implement dedicated API endpoints that match the drag-drop operations:
|
||||
1. **Update Position** - Move an item to an empty slot
|
||||
2. **Swap Items** - Exchange positions of two items
|
||||
3. **Batch Update** - Handle complex multi-item operations atomically
|
||||
|
||||
## API Specifications
|
||||
|
||||
### 1. Update Position Endpoints
|
||||
|
||||
Move a grid item to a new empty position within the same or different container.
|
||||
|
||||
#### Weapons
|
||||
```
|
||||
PUT /api/v1/parties/:party_id/grid_weapons/:id/position
|
||||
```
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"position": 5,
|
||||
"container": "main" | "extra"
|
||||
}
|
||||
```
|
||||
|
||||
**Response (200 OK):**
|
||||
```json
|
||||
{
|
||||
"party": { /* updated party object */ },
|
||||
"grid_weapon": { /* updated grid weapon */ }
|
||||
}
|
||||
```
|
||||
|
||||
#### Characters
|
||||
```
|
||||
PUT /api/v1/parties/:party_id/grid_characters/:id/position
|
||||
```
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"position": 2,
|
||||
"container": "main" | "extra"
|
||||
}
|
||||
```
|
||||
|
||||
**Special Rules:**
|
||||
- Characters must maintain sequential filling (no gaps)
|
||||
- Server should auto-compact positions after move
|
||||
|
||||
**Response (200 OK):**
|
||||
```json
|
||||
{
|
||||
"party": { /* updated party object */ },
|
||||
"grid_character": { /* updated grid character */ },
|
||||
"reordered": true // Indicates if sequential filling was applied
|
||||
}
|
||||
```
|
||||
|
||||
#### Summons
|
||||
```
|
||||
PUT /api/v1/parties/:party_id/grid_summons/:id/position
|
||||
```
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"position": 1,
|
||||
"container": "main" | "subaura"
|
||||
}
|
||||
```
|
||||
|
||||
**Response (200 OK):**
|
||||
```json
|
||||
{
|
||||
"party": { /* updated party object */ },
|
||||
"grid_summon": { /* updated grid summon */ }
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Swap Endpoints
|
||||
|
||||
Exchange positions between two grid items of the same type.
|
||||
|
||||
#### Weapons
|
||||
```
|
||||
POST /api/v1/parties/:party_id/grid_weapons/swap
|
||||
```
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"source_id": "uuid-1",
|
||||
"target_id": "uuid-2"
|
||||
}
|
||||
```
|
||||
|
||||
**Response (200 OK):**
|
||||
```json
|
||||
{
|
||||
"party": { /* updated party object */ },
|
||||
"swapped": {
|
||||
"source": { /* updated source weapon */ },
|
||||
"target": { /* updated target weapon */ }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Characters
|
||||
```
|
||||
POST /api/v1/parties/:party_id/grid_characters/swap
|
||||
```
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"source_id": "uuid-1",
|
||||
"target_id": "uuid-2"
|
||||
}
|
||||
```
|
||||
|
||||
**Response (200 OK):**
|
||||
```json
|
||||
{
|
||||
"party": { /* updated party object */ },
|
||||
"swapped": {
|
||||
"source": { /* updated source character */ },
|
||||
"target": { /* updated target character */ }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Summons
|
||||
```
|
||||
POST /api/v1/parties/:party_id/grid_summons/swap
|
||||
```
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"source_id": "uuid-1",
|
||||
"target_id": "uuid-2"
|
||||
}
|
||||
```
|
||||
|
||||
**Response (200 OK):**
|
||||
```json
|
||||
{
|
||||
"party": { /* updated party object */ },
|
||||
"swapped": {
|
||||
"source": { /* updated source summon */ },
|
||||
"target": { /* updated target summon */ }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Batch Grid Update Endpoint
|
||||
|
||||
Handle complex multi-item operations atomically.
|
||||
|
||||
```
|
||||
POST /api/v1/parties/:party_id/grid_update
|
||||
```
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"operations": [
|
||||
{
|
||||
"type": "move",
|
||||
"entity": "character",
|
||||
"id": "uuid-1",
|
||||
"position": 2,
|
||||
"container": "main"
|
||||
},
|
||||
{
|
||||
"type": "swap",
|
||||
"entity": "weapon",
|
||||
"source_id": "uuid-2",
|
||||
"target_id": "uuid-3"
|
||||
},
|
||||
{
|
||||
"type": "remove",
|
||||
"entity": "summon",
|
||||
"id": "uuid-4"
|
||||
}
|
||||
],
|
||||
"options": {
|
||||
"maintain_character_sequence": true,
|
||||
"validate_before_execute": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Response (200 OK):**
|
||||
```json
|
||||
{
|
||||
"party": { /* fully updated party object */ },
|
||||
"operations_applied": 3,
|
||||
"changes": [
|
||||
{ "entity": "character", "id": "uuid-1", "action": "moved", "from": 0, "to": 2 },
|
||||
{ "entity": "weapon", "id": "uuid-2", "action": "swapped", "with": "uuid-3" },
|
||||
{ "entity": "summon", "id": "uuid-4", "action": "removed" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Business Rules
|
||||
|
||||
### Position Constraints
|
||||
|
||||
#### Characters (0-4, 5-6 for extra)
|
||||
- **Main slots (0-4):** Must be sequential, no gaps allowed
|
||||
- **Extra slots (5-6):** Can have gaps
|
||||
- **Auto-compact:** When moving/removing, shift remaining characters to maintain sequence
|
||||
|
||||
#### Weapons (-1 mainhand, 0-8 grid, 9+ extra)
|
||||
- **Mainhand (-1):** Not draggable, cannot be target
|
||||
- **Grid slots (0-8):** Can have gaps
|
||||
- **Extra slots (9+):** Can have gaps
|
||||
|
||||
#### Summons (-1 main, 0-3 sub, 4-5 subaura, 6 friend)
|
||||
- **Main (-1):** Not draggable, cannot be target
|
||||
- **Sub slots (0-3):** Can have gaps
|
||||
- **Subaura (4-5):** Can have gaps
|
||||
- **Friend (6):** Not draggable, cannot be target
|
||||
|
||||
### Validation Rules
|
||||
|
||||
1. **Type Matching**
|
||||
- Can only swap/move items of the same type
|
||||
- Cannot mix characters, weapons, and summons
|
||||
|
||||
2. **Position Validation**
|
||||
- Target position must be valid for the entity type
|
||||
- Cannot move to restricted positions (mainhand, main summon, friend)
|
||||
|
||||
3. **Container Rules**
|
||||
- Items can move between containers (main ↔ extra)
|
||||
- Container must be valid for the entity type
|
||||
|
||||
4. **Conflict Resolution**
|
||||
- For weapons: Check Ultima/Opus conflicts
|
||||
- For summons: Check duplicate restrictions
|
||||
- Apply same rules as create operations
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Common Error Responses
|
||||
|
||||
#### 400 Bad Request
|
||||
```json
|
||||
{
|
||||
"error": "Invalid position",
|
||||
"details": {
|
||||
"position": 10,
|
||||
"max_position": 8,
|
||||
"entity": "weapon"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 403 Forbidden
|
||||
```json
|
||||
{
|
||||
"error": "Cannot modify restricted slot",
|
||||
"details": {
|
||||
"slot": "mainhand",
|
||||
"reason": "Mainhand weapon cannot be moved via drag-drop"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 409 Conflict
|
||||
```json
|
||||
{
|
||||
"error": "Operation would create invalid state",
|
||||
"details": {
|
||||
"reason": "Cannot have two Ultima weapons in grid",
|
||||
"conflicting_items": ["uuid-1", "uuid-2"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 422 Unprocessable Entity
|
||||
```json
|
||||
{
|
||||
"error": "Validation failed",
|
||||
"details": {
|
||||
"source_id": ["not found"],
|
||||
"target_id": ["belongs to different party"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Rollback Strategy
|
||||
|
||||
For batch operations:
|
||||
1. Validate all operations before executing any
|
||||
2. Use database transaction for atomicity
|
||||
3. If any operation fails, rollback entire batch
|
||||
4. Return detailed error showing which operation failed
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
### Backend (Rails API)
|
||||
|
||||
1. **Controller Actions**
|
||||
- Add `update_position` action to grid controllers
|
||||
- Add `swap` action to grid controllers
|
||||
- Add `grid_update` action to parties controller
|
||||
|
||||
2. **Model Methods**
|
||||
- `GridWeapon#update_position(new_position, container = nil)`
|
||||
- `GridCharacter#update_position(new_position, container = nil)`
|
||||
- `GridSummon#update_position(new_position, container = nil)`
|
||||
- `Party#swap_items(source_item, target_item)`
|
||||
- `Party#apply_grid_operations(operations)`
|
||||
|
||||
3. **Validations**
|
||||
- Add position range validators
|
||||
- Add container validators
|
||||
- Ensure conflict rules are checked
|
||||
|
||||
4. **Authorization**
|
||||
- Require edit permission (user ownership or edit key)
|
||||
- Use existing `authorize_party_edit!` pattern
|
||||
|
||||
### Frontend Integration
|
||||
|
||||
1. **API Client Updates**
|
||||
```typescript
|
||||
// Add to apiClient
|
||||
async updateWeaponPosition(partyId, weaponId, position, container)
|
||||
async updateCharacterPosition(partyId, characterId, position, container)
|
||||
async updateSummonPosition(partyId, summonId, position, container)
|
||||
async swapWeapons(partyId, sourceId, targetId)
|
||||
async swapCharacters(partyId, sourceId, targetId)
|
||||
async swapSummons(partyId, sourceId, targetId)
|
||||
async batchGridUpdate(partyId, operations)
|
||||
```
|
||||
|
||||
2. **Drag Handler Updates**
|
||||
```typescript
|
||||
// In Party.svelte
|
||||
async function handleMove(source, target) {
|
||||
const result = await apiClient.updateWeaponPosition(
|
||||
party.id,
|
||||
source.itemId,
|
||||
target.position,
|
||||
target.container
|
||||
)
|
||||
party = result.party
|
||||
}
|
||||
|
||||
async function handleSwap(source, target) {
|
||||
const result = await apiClient.swapWeapons(
|
||||
party.id,
|
||||
source.itemId,
|
||||
target.itemId
|
||||
)
|
||||
party = result.party
|
||||
}
|
||||
```
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
### Phase 1: Backend Implementation
|
||||
1. Implement new endpoints in Rails API
|
||||
2. Add comprehensive tests
|
||||
3. Deploy to staging
|
||||
|
||||
### Phase 2: Frontend Integration
|
||||
1. Add new methods to API client
|
||||
2. Update drag handlers to use new endpoints
|
||||
3. Keep fallback to old method temporarily
|
||||
|
||||
### Phase 3: Validation
|
||||
1. Test all drag-drop scenarios
|
||||
2. Verify data integrity
|
||||
3. Monitor for errors
|
||||
|
||||
### Phase 4: Cleanup
|
||||
1. Remove old implementation
|
||||
2. Remove fallback code
|
||||
3. Update documentation
|
||||
|
||||
## Success Metrics
|
||||
|
||||
1. **Performance**
|
||||
- Single API call for position updates (vs 2-4 calls)
|
||||
- Response time < 200ms for position updates
|
||||
- Response time < 300ms for swaps
|
||||
|
||||
2. **Reliability**
|
||||
- Zero data inconsistencies
|
||||
- Atomic operations (no partial updates)
|
||||
- Proper rollback on failures
|
||||
|
||||
3. **User Experience**
|
||||
- Immediate visual feedback
|
||||
- Smooth animations
|
||||
- No lost changes
|
||||
|
||||
4. **Developer Experience**
|
||||
- Clean, intuitive API
|
||||
- Comprehensive error messages
|
||||
- Easy to debug issues
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **Authorization**
|
||||
- Verify party ownership or edit key
|
||||
- Validate all IDs belong to specified party
|
||||
- Rate limiting on batch operations
|
||||
|
||||
2. **Input Validation**
|
||||
- Sanitize all position values
|
||||
- Validate container strings
|
||||
- Check array bounds
|
||||
|
||||
3. **Audit Trail**
|
||||
- Log all position changes
|
||||
- Track user/edit key for each operation
|
||||
- Monitor for suspicious patterns
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
1. **Undo/Redo Support**
|
||||
- Track operation history
|
||||
- Implement reverse operations
|
||||
- Client-side undo stack
|
||||
|
||||
2. **Optimistic Updates**
|
||||
- Apply changes immediately in UI
|
||||
- Rollback on server rejection
|
||||
- Queue operations for offline support
|
||||
|
||||
3. **Bulk Operations**
|
||||
- "Auto-arrange" endpoint
|
||||
- "Clear grid" endpoint
|
||||
- "Copy from template" endpoint
|
||||
|
||||
4. **WebSocket Support**
|
||||
- Real-time updates for shared parties
|
||||
- Conflict resolution for simultaneous edits
|
||||
- Live collaboration features
|
||||
|
||||
## Conclusion
|
||||
|
||||
These API endpoints will provide a robust foundation for drag-drop functionality, ensuring data consistency, good performance, and excellent user experience. The atomic nature of these operations will eliminate current issues with partial updates and race conditions.
|
||||
1003
docs/drag-drop-prd.md
Normal file
1003
docs/drag-drop-prd.md
Normal file
File diff suppressed because it is too large
Load diff
764
docs/infinite-scroll-implementation.md
Normal file
764
docs/infinite-scroll-implementation.md
Normal file
|
|
@ -0,0 +1,764 @@
|
|||
# Infinite Scrolling Implementation with Runed
|
||||
|
||||
## Overview
|
||||
|
||||
This document outlines the implementation of infinite scrolling for the Hensei application using Runed's utilities instead of TanStack Query. Runed provides Svelte 5-specific reactive utilities that work seamlessly with runes.
|
||||
|
||||
## Current State Analysis
|
||||
|
||||
### What We Have
|
||||
- **Runed v0.31.1** installed and actively used in the project
|
||||
- Established resource patterns (`search.resource.svelte.ts`, `party.resource.svelte.ts`)
|
||||
- Pagination with "Previous/Next" links on profile and explore pages
|
||||
- API support for pagination via `page` parameter
|
||||
- SSR with SvelteKit for initial page loads
|
||||
|
||||
### What We Need
|
||||
- Automatic loading of next page when user scrolls near bottom
|
||||
- Seamless data accumulation without page refreshes
|
||||
- Loading indicators and error states
|
||||
- Memory-efficient data management
|
||||
- Accessibility support
|
||||
|
||||
## Architecture Design
|
||||
|
||||
### Core Components
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ InfiniteScroll Component │
|
||||
│ - Sentinel element for intersection detection │
|
||||
│ - Loading/error UI states │
|
||||
│ - Accessibility features │
|
||||
└──────────────────┬──────────────────────────────┘
|
||||
│
|
||||
┌──────────────────▼──────────────────────────────┐
|
||||
│ InfiniteScrollResource Class │
|
||||
│ - IsInViewport/useIntersectionObserver │
|
||||
│ - State management with $state runes │
|
||||
│ - Page tracking and data accumulation │
|
||||
│ - Loading/error state handling │
|
||||
└──────────────────┬──────────────────────────────┘
|
||||
│
|
||||
┌──────────────────▼──────────────────────────────┐
|
||||
│ Existing Adapters (Enhanced) │
|
||||
│ - PartyAdapter │
|
||||
│ - UserAdapter │
|
||||
│ - Support for incremental data fetching │
|
||||
└──────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### 1. InfiniteScrollResource Class
|
||||
|
||||
Location: `/src/lib/api/adapters/resources/infiniteScroll.resource.svelte.ts`
|
||||
|
||||
```typescript
|
||||
import { IsInViewport, watch, useDebounce } from 'runed'
|
||||
import type { AdapterError } from '../types'
|
||||
|
||||
export interface InfiniteScrollOptions<T> {
|
||||
fetcher: (page: number) => Promise<{
|
||||
results: T[]
|
||||
page: number
|
||||
totalPages: number
|
||||
total: number
|
||||
}>
|
||||
initialData?: T[]
|
||||
pageSize?: number
|
||||
threshold?: number // pixels before viewport edge to trigger load
|
||||
debounceMs?: number
|
||||
maxItems?: number // optional limit for memory management
|
||||
}
|
||||
|
||||
export class InfiniteScrollResource<T> {
|
||||
// Reactive state
|
||||
items = $state<T[]>([])
|
||||
page = $state(1)
|
||||
totalPages = $state<number | undefined>()
|
||||
total = $state<number | undefined>()
|
||||
loading = $state(false)
|
||||
loadingMore = $state(false)
|
||||
error = $state<AdapterError | undefined>()
|
||||
|
||||
// Sentinel element for intersection detection
|
||||
sentinelElement = $state<HTMLElement | undefined>()
|
||||
|
||||
// Viewport detection using Runed
|
||||
private inViewport: IsInViewport
|
||||
|
||||
// Configuration
|
||||
private fetcher: InfiniteScrollOptions<T>['fetcher']
|
||||
private threshold: number
|
||||
private maxItems?: number
|
||||
|
||||
// Abort controller for cancellation
|
||||
private abortController?: AbortController
|
||||
|
||||
constructor(options: InfiniteScrollOptions<T>) {
|
||||
this.fetcher = options.fetcher
|
||||
this.threshold = options.threshold ?? 200
|
||||
this.maxItems = options.maxItems
|
||||
|
||||
if (options.initialData) {
|
||||
this.items = options.initialData
|
||||
}
|
||||
|
||||
// Set up viewport detection
|
||||
this.inViewport = new IsInViewport(
|
||||
() => this.sentinelElement,
|
||||
{ rootMargin: `${this.threshold}px` }
|
||||
)
|
||||
|
||||
// Create debounced load function if specified
|
||||
const loadMoreFn = options.debounceMs
|
||||
? useDebounce(() => this.loadMore(), () => options.debounceMs!)
|
||||
: () => this.loadMore()
|
||||
|
||||
// Watch for visibility changes
|
||||
watch(
|
||||
() => this.inViewport.current,
|
||||
(isVisible) => {
|
||||
if (isVisible && !this.loading && !this.loadingMore && this.hasMore) {
|
||||
loadMoreFn()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Computed properties
|
||||
get hasMore() {
|
||||
return this.totalPages === undefined || this.page < this.totalPages
|
||||
}
|
||||
|
||||
get isEmpty() {
|
||||
return this.items.length === 0 && !this.loading
|
||||
}
|
||||
|
||||
// Load initial data or reset
|
||||
async load() {
|
||||
this.reset()
|
||||
this.loading = true
|
||||
this.error = undefined
|
||||
|
||||
try {
|
||||
const response = await this.fetcher(1)
|
||||
this.items = response.results
|
||||
this.page = response.page
|
||||
this.totalPages = response.totalPages
|
||||
this.total = response.total
|
||||
} catch (err) {
|
||||
this.error = err as AdapterError
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
// Load next page
|
||||
async loadMore() {
|
||||
if (!this.hasMore || this.loadingMore || this.loading) return
|
||||
|
||||
this.loadingMore = true
|
||||
this.error = undefined
|
||||
|
||||
// Cancel previous request if any
|
||||
this.abortController?.abort()
|
||||
this.abortController = new AbortController()
|
||||
|
||||
try {
|
||||
const nextPage = this.page + 1
|
||||
const response = await this.fetcher(nextPage)
|
||||
|
||||
// Append new items
|
||||
this.items = [...this.items, ...response.results]
|
||||
|
||||
// Trim items if max limit is set
|
||||
if (this.maxItems && this.items.length > this.maxItems) {
|
||||
this.items = this.items.slice(-this.maxItems)
|
||||
}
|
||||
|
||||
this.page = response.page
|
||||
this.totalPages = response.totalPages
|
||||
this.total = response.total
|
||||
} catch (err: any) {
|
||||
if (err.name !== 'AbortError') {
|
||||
this.error = err as AdapterError
|
||||
}
|
||||
} finally {
|
||||
this.loadingMore = false
|
||||
this.abortController = undefined
|
||||
}
|
||||
}
|
||||
|
||||
// Manual trigger for load more (fallback button)
|
||||
async retry() {
|
||||
if (this.error) {
|
||||
await this.loadMore()
|
||||
}
|
||||
}
|
||||
|
||||
// Reset to initial state
|
||||
reset() {
|
||||
this.items = []
|
||||
this.page = 1
|
||||
this.totalPages = undefined
|
||||
this.total = undefined
|
||||
this.loading = false
|
||||
this.loadingMore = false
|
||||
this.error = undefined
|
||||
this.abortController?.abort()
|
||||
}
|
||||
|
||||
// Bind sentinel element
|
||||
bindSentinel(element: HTMLElement) {
|
||||
this.sentinelElement = element
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
destroy() {
|
||||
this.abortController?.abort()
|
||||
this.inViewport.stop()
|
||||
}
|
||||
}
|
||||
|
||||
// Factory function
|
||||
export function createInfiniteScrollResource<T>(
|
||||
options: InfiniteScrollOptions<T>
|
||||
): InfiniteScrollResource<T> {
|
||||
return new InfiniteScrollResource(options)
|
||||
}
|
||||
```
|
||||
|
||||
### 2. InfiniteScroll Component
|
||||
|
||||
Location: `/src/lib/components/InfiniteScroll.svelte`
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import type { InfiniteScrollResource } from '$lib/api/adapters/resources/infiniteScroll.resource.svelte'
|
||||
import type { Snippet } from 'svelte'
|
||||
|
||||
interface Props {
|
||||
resource: InfiniteScrollResource<any>
|
||||
children: Snippet
|
||||
loadingSnippet?: Snippet
|
||||
errorSnippet?: Snippet<[Error]>
|
||||
emptySnippet?: Snippet
|
||||
endSnippet?: Snippet
|
||||
class?: string
|
||||
}
|
||||
|
||||
const {
|
||||
resource,
|
||||
children,
|
||||
loadingSnippet,
|
||||
errorSnippet,
|
||||
emptySnippet,
|
||||
endSnippet,
|
||||
class: className = ''
|
||||
}: Props = $props()
|
||||
|
||||
// Bind sentinel element
|
||||
let sentinel: HTMLElement
|
||||
$effect(() => {
|
||||
if (sentinel) {
|
||||
resource.bindSentinel(sentinel)
|
||||
}
|
||||
})
|
||||
|
||||
// Cleanup on unmount
|
||||
$effect(() => {
|
||||
return () => resource.destroy()
|
||||
})
|
||||
|
||||
// Accessibility: Announce new content
|
||||
$effect(() => {
|
||||
if (resource.loadingMore) {
|
||||
announceToScreenReader('Loading more items...')
|
||||
}
|
||||
})
|
||||
|
||||
function announceToScreenReader(message: string) {
|
||||
const announcement = document.createElement('div')
|
||||
announcement.setAttribute('role', 'status')
|
||||
announcement.setAttribute('aria-live', 'polite')
|
||||
announcement.className = 'sr-only'
|
||||
announcement.textContent = message
|
||||
document.body.appendChild(announcement)
|
||||
setTimeout(() => announcement.remove(), 1000)
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="infinite-scroll-container {className}">
|
||||
<!-- Main content -->
|
||||
{@render children()}
|
||||
|
||||
<!-- Loading indicator for initial load -->
|
||||
{#if resource.loading}
|
||||
{#if loadingSnippet}
|
||||
{@render loadingSnippet()}
|
||||
{:else}
|
||||
<div class="loading-initial">
|
||||
<span class="spinner"></span>
|
||||
Loading...
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Empty state -->
|
||||
{#if resource.isEmpty && !resource.loading}
|
||||
{#if emptySnippet}
|
||||
{@render emptySnippet()}
|
||||
{:else}
|
||||
<div class="empty-state">No items found</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Sentinel element for intersection observer -->
|
||||
{#if !resource.loading && resource.hasMore}
|
||||
<div
|
||||
bind:this={sentinel}
|
||||
class="sentinel"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
{/if}
|
||||
|
||||
<!-- Loading more indicator -->
|
||||
{#if resource.loadingMore}
|
||||
<div class="loading-more">
|
||||
<span class="spinner"></span>
|
||||
Loading more...
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Error state with retry -->
|
||||
{#if resource.error && !resource.loadingMore}
|
||||
{#if errorSnippet}
|
||||
{@render errorSnippet(resource.error)}
|
||||
{:else}
|
||||
<div class="error-state">
|
||||
<p>Failed to load more items</p>
|
||||
<button onclick={() => resource.retry()}>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- End of list indicator -->
|
||||
{#if !resource.hasMore && !resource.isEmpty}
|
||||
{#if endSnippet}
|
||||
{@render endSnippet()}
|
||||
{:else}
|
||||
<div class="end-state">No more items to load</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Fallback load more button for accessibility -->
|
||||
{#if resource.hasMore && !resource.loadingMore && !resource.loading}
|
||||
<button
|
||||
class="load-more-fallback"
|
||||
onclick={() => resource.loadMore()}
|
||||
aria-label="Load more items"
|
||||
>
|
||||
Load More
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
@use '$src/themes/spacing' as *;
|
||||
@use '$src/themes/colors' as *;
|
||||
|
||||
.infinite-scroll-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sentinel {
|
||||
height: 1px;
|
||||
margin-top: -200px; // Trigger before reaching actual end
|
||||
}
|
||||
|
||||
.loading-initial,
|
||||
.loading-more,
|
||||
.error-state,
|
||||
.empty-state,
|
||||
.end-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: $unit-4x;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
display: inline-block;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 3px solid rgba(0, 0, 0, 0.1);
|
||||
border-left-color: var(--primary-color);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.load-more-fallback {
|
||||
display: block;
|
||||
margin: $unit-2x auto;
|
||||
padding: $unit $unit-2x;
|
||||
background: var(--button-bg);
|
||||
color: var(--button-text);
|
||||
border: 1px solid var(--button-border);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
|
||||
// Only show for keyboard/screen reader users
|
||||
&:not(:focus) {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
### 3. Enhanced Party Resource
|
||||
|
||||
Location: Update `/src/lib/api/adapters/resources/party.resource.svelte.ts`
|
||||
|
||||
Add infinite scroll support to existing PartyResource:
|
||||
|
||||
```typescript
|
||||
// Add to existing PartyResource class
|
||||
|
||||
// Infinite scroll for explore/gallery
|
||||
exploreInfinite = createInfiniteScrollResource<Party>({
|
||||
fetcher: async (page) => {
|
||||
return await this.adapter.list({ page })
|
||||
},
|
||||
debounceMs: 200,
|
||||
threshold: 300
|
||||
})
|
||||
|
||||
// Infinite scroll for user parties
|
||||
userPartiesInfinite = createInfiniteScrollResource<Party>({
|
||||
fetcher: async (page) => {
|
||||
const username = this.currentUsername // store username when loading
|
||||
return await this.adapter.listUserParties({ username, page })
|
||||
},
|
||||
debounceMs: 200,
|
||||
threshold: 300
|
||||
})
|
||||
```
|
||||
|
||||
### 4. Usage in Routes
|
||||
|
||||
#### Profile Page (`/src/routes/[username]/+page.svelte`)
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types'
|
||||
import { InfiniteScroll } from '$lib/components/InfiniteScroll.svelte'
|
||||
import ExploreGrid from '$lib/components/explore/ExploreGrid.svelte'
|
||||
import { createInfiniteScrollResource } from '$lib/api/adapters/resources'
|
||||
import { userAdapter } from '$lib/api/adapters'
|
||||
import { getAvatarSrc, getAvatarSrcSet } from '$lib/utils/avatar'
|
||||
|
||||
const { data } = $props() as { data: PageData }
|
||||
|
||||
// Create infinite scroll resource
|
||||
const profileResource = createInfiniteScrollResource({
|
||||
fetcher: async (page) => {
|
||||
const tab = data.tab || 'teams'
|
||||
if (tab === 'favorites' && data.isOwner) {
|
||||
return await userAdapter.getFavorites({ page })
|
||||
}
|
||||
return await userAdapter.getProfile(data.user.username, page)
|
||||
},
|
||||
initialData: data.items,
|
||||
debounceMs: 200
|
||||
})
|
||||
|
||||
// Initialize with SSR data
|
||||
$effect(() => {
|
||||
if (data.items && profileResource.items.length === 0) {
|
||||
profileResource.items = data.items
|
||||
profileResource.page = data.page || 1
|
||||
profileResource.totalPages = data.totalPages
|
||||
profileResource.total = data.total
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<section class="profile">
|
||||
<header class="header">
|
||||
<!-- Header content unchanged -->
|
||||
</header>
|
||||
|
||||
<InfiniteScroll resource={profileResource}>
|
||||
<ExploreGrid items={profileResource.items} />
|
||||
|
||||
{#snippet emptySnippet()}
|
||||
<p class="empty">No teams found</p>
|
||||
{/snippet}
|
||||
|
||||
{#snippet endSnippet()}
|
||||
<p class="end-message">You've reached the end!</p>
|
||||
{/snippet}
|
||||
</InfiniteScroll>
|
||||
</section>
|
||||
```
|
||||
|
||||
#### Explore Page (`/src/routes/teams/explore/+page.svelte`)
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types'
|
||||
import { InfiniteScroll } from '$lib/components/InfiniteScroll.svelte'
|
||||
import ExploreGrid from '$lib/components/explore/ExploreGrid.svelte'
|
||||
import { createInfiniteScrollResource } from '$lib/api/adapters/resources'
|
||||
import { partyAdapter } from '$lib/api/adapters'
|
||||
|
||||
const { data } = $props() as { data: PageData }
|
||||
|
||||
// Create infinite scroll resource
|
||||
const exploreResource = createInfiniteScrollResource({
|
||||
fetcher: (page) => partyAdapter.list({ page }),
|
||||
initialData: data.items,
|
||||
pageSize: 20,
|
||||
maxItems: 200 // Limit for performance
|
||||
})
|
||||
|
||||
// Initialize with SSR data
|
||||
$effect(() => {
|
||||
if (data.items && exploreResource.items.length === 0) {
|
||||
exploreResource.items = data.items
|
||||
exploreResource.page = data.page || 1
|
||||
exploreResource.totalPages = data.totalPages
|
||||
exploreResource.total = data.total
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<section class="explore">
|
||||
<header>
|
||||
<h1>Explore Teams</h1>
|
||||
</header>
|
||||
|
||||
<InfiniteScroll resource={exploreResource} class="explore-grid">
|
||||
<ExploreGrid items={exploreResource.items} />
|
||||
</InfiniteScroll>
|
||||
</section>
|
||||
```
|
||||
|
||||
## Performance Optimizations
|
||||
|
||||
### 1. Virtual Scrolling (Optional)
|
||||
For extremely large lists (>500 items), consider implementing virtual scrolling:
|
||||
- Use a library like `@tanstack/virtual` or build custom with Svelte
|
||||
- Only render visible items + buffer
|
||||
- Maintain scroll position with placeholder elements
|
||||
|
||||
### 2. Memory Management
|
||||
- Set `maxItems` limit to prevent unbounded growth
|
||||
- Implement "windowing" - keep only N pages in memory
|
||||
- Clear old pages when scrolling forward
|
||||
|
||||
### 3. Request Optimization
|
||||
- Debounce scroll events (built-in with `debounceMs`)
|
||||
- Cancel in-flight requests when component unmounts
|
||||
- Implement request deduplication
|
||||
|
||||
### 4. Caching Strategy
|
||||
- Cache fetched pages in adapter layer
|
||||
- Implement stale-while-revalidate pattern
|
||||
- Clear cache on user actions (create, update, delete)
|
||||
|
||||
## Accessibility Features
|
||||
|
||||
### 1. Keyboard Navigation
|
||||
- Hidden "Load More" button accessible via Tab
|
||||
- Focus management when new content loads
|
||||
- Skip links to bypass loaded content
|
||||
|
||||
### 2. Screen Reader Support
|
||||
- Announce when new content is loading
|
||||
- Announce when new content has loaded
|
||||
- Announce total item count
|
||||
- Announce when end is reached
|
||||
|
||||
### 3. Reduced Motion
|
||||
```css
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.spinner {
|
||||
animation: none;
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. ARIA Attributes
|
||||
- `role="status"` for loading indicators
|
||||
- `aria-live="polite"` for announcements
|
||||
- `aria-busy="true"` during loading
|
||||
- `aria-label` for interactive elements
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### 1. Unit Tests
|
||||
```typescript
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { InfiniteScrollResource } from '$lib/api/adapters/resources/infiniteScroll.resource.svelte'
|
||||
|
||||
describe('InfiniteScrollResource', () => {
|
||||
it('loads initial data', async () => {
|
||||
const resource = createInfiniteScrollResource({
|
||||
fetcher: mockFetcher,
|
||||
initialData: mockData
|
||||
})
|
||||
|
||||
expect(resource.items).toEqual(mockData)
|
||||
})
|
||||
|
||||
it('loads more when triggered', async () => {
|
||||
const resource = createInfiniteScrollResource({
|
||||
fetcher: mockFetcher
|
||||
})
|
||||
|
||||
await resource.load()
|
||||
const initialCount = resource.items.length
|
||||
|
||||
await resource.loadMore()
|
||||
expect(resource.items.length).toBeGreaterThan(initialCount)
|
||||
})
|
||||
|
||||
it('stops loading when no more pages', async () => {
|
||||
// Test hasMore property
|
||||
})
|
||||
|
||||
it('handles errors gracefully', async () => {
|
||||
// Test error states
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### 2. Integration Tests
|
||||
- Test scroll trigger at various speeds
|
||||
- Test with slow network (throttling)
|
||||
- Test error recovery
|
||||
- Test memory limits
|
||||
- Test accessibility features
|
||||
|
||||
### 3. E2E Tests
|
||||
```typescript
|
||||
test('infinite scroll loads more content', async ({ page }) => {
|
||||
await page.goto('/teams/explore')
|
||||
|
||||
// Initial content should be visible
|
||||
await expect(page.locator('.grid-item')).toHaveCount(20)
|
||||
|
||||
// Scroll to bottom
|
||||
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight))
|
||||
|
||||
// Wait for more content to load
|
||||
await page.waitForSelector('.grid-item:nth-child(21)')
|
||||
|
||||
// Verify more items loaded
|
||||
const itemCount = await page.locator('.grid-item').count()
|
||||
expect(itemCount).toBeGreaterThan(20)
|
||||
})
|
||||
```
|
||||
|
||||
## Migration Path
|
||||
|
||||
### Phase 1: Infrastructure
|
||||
1. Create InfiniteScrollResource class
|
||||
2. Create InfiniteScroll component
|
||||
3. Write unit tests
|
||||
|
||||
### Phase 2: Implementation
|
||||
1. Update explore page (lowest risk)
|
||||
2. Update profile pages
|
||||
3. Update other paginated lists
|
||||
|
||||
### Phase 3: Optimization
|
||||
1. Add virtual scrolling if needed
|
||||
2. Implement advanced caching
|
||||
3. Performance monitoring
|
||||
|
||||
### Phase 4: Polish
|
||||
1. Refine loading indicators
|
||||
2. Enhance error states
|
||||
3. Improve accessibility
|
||||
4. Add analytics
|
||||
|
||||
## Rollback Plan
|
||||
|
||||
If infinite scrolling causes issues:
|
||||
1. Keep pagination code in place (commented)
|
||||
2. Use feature flag to toggle between pagination and infinite scroll
|
||||
3. Can revert per-route if needed
|
||||
|
||||
```typescript
|
||||
const useInfiniteScroll = $state(
|
||||
localStorage.getItem('feature:infinite-scroll') !== 'false'
|
||||
)
|
||||
|
||||
{#if useInfiniteScroll}
|
||||
<InfiniteScroll>...</InfiniteScroll>
|
||||
{:else}
|
||||
<Pagination>...</Pagination>
|
||||
{/if}
|
||||
```
|
||||
|
||||
## Benefits Over TanStack Query
|
||||
|
||||
1. **Native Svelte 5**: Built specifically for Svelte runes
|
||||
2. **Simpler API**: No provider setup required
|
||||
3. **Smaller Bundle**: Runed is lightweight
|
||||
4. **Better Integration**: Works seamlessly with SvelteKit
|
||||
5. **Type Safety**: Full TypeScript support with runes
|
||||
|
||||
## Potential Challenges
|
||||
|
||||
1. **SSR Hydration**: Ensure client picks up where server left off
|
||||
2. **Back Navigation**: Restore scroll position to correct item
|
||||
3. **Memory Leaks**: Proper cleanup of observers and listeners
|
||||
4. **Race Conditions**: Handle rapid scrolling/navigation
|
||||
5. **Error Recovery**: Graceful handling of network failures
|
||||
|
||||
## References
|
||||
|
||||
- [Runed Documentation](https://runed.dev/docs)
|
||||
- [Runed IsInViewport](https://runed.dev/docs/utilities/is-in-viewport)
|
||||
- [Runed useIntersectionObserver](https://runed.dev/docs/utilities/use-intersection-observer)
|
||||
- [Runed Resource Pattern](https://runed.dev/docs/utilities/resource)
|
||||
- [Svelte 5 Runes](https://svelte.dev/docs/svelte/runes)
|
||||
- [IntersectionObserver API](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API)
|
||||
|
||||
## Conclusion
|
||||
|
||||
This implementation leverages Runed's powerful utilities to create a robust, accessible, and performant infinite scrolling solution that integrates seamlessly with the existing Hensei application architecture. The approach follows established patterns in the codebase while adding modern UX improvements.
|
||||
259
docs/scroll-restoration-implementation.md
Normal file
259
docs/scroll-restoration-implementation.md
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
# Scroll Restoration Implementation for Custom Containers
|
||||
|
||||
## Problem Description
|
||||
|
||||
In our SvelteKit application, scroll position isn't resetting when navigating between pages. This issue occurs because:
|
||||
|
||||
1. The application uses a custom scrolling container (`.main-content`) instead of the default window/body scrolling
|
||||
2. SvelteKit's built-in scroll restoration only works with window-level scrolling
|
||||
3. When navigating from a scrolled profile page to a team detail page, the detail page appears scrolled down instead of at the top
|
||||
|
||||
### User Experience Impact
|
||||
- Users scroll down on a profile page
|
||||
- Click on a team to view details
|
||||
- The team detail page is already scrolled down (unexpected)
|
||||
- This breaks the expected navigation behavior where new pages start at the top
|
||||
|
||||
## Research Findings
|
||||
|
||||
### SvelteKit's Default Behavior
|
||||
- SvelteKit automatically handles scroll restoration for window-level scrolling
|
||||
- It stores scroll positions in `sessionStorage` for browser back/forward navigation
|
||||
- The `afterNavigate` and `beforeNavigate` hooks provide navigation lifecycle control
|
||||
- The navigation `type` parameter distinguishes between different navigation methods
|
||||
|
||||
### Limitations with Custom Scroll Containers
|
||||
- SvelteKit's scroll handling doesn't automatically work with custom containers (GitHub issues [#937](https://github.com/sveltejs/kit/issues/937), [#2733](https://github.com/sveltejs/kit/issues/2733))
|
||||
- The framework only tracks `window.scrollY`, not element-specific scroll positions
|
||||
- Using `disableScrollHandling()` is discouraged as it "breaks user expectations" (official docs)
|
||||
|
||||
### Community Solutions
|
||||
1. Manual scroll management using navigation hooks
|
||||
2. Combining `beforeNavigate` for saving positions with `afterNavigate` for restoration
|
||||
3. Using the snapshot API for session persistence
|
||||
4. Leveraging `requestAnimationFrame` to ensure DOM readiness
|
||||
|
||||
## Solution Architecture
|
||||
|
||||
### Core Components
|
||||
|
||||
1. **Scroll Position Storage**: A Map that stores scroll positions keyed by URL
|
||||
2. **Navigation Hooks**: Using `beforeNavigate` and `afterNavigate` for lifecycle management
|
||||
3. **Navigation Type Detection**: Using the `type` parameter to distinguish navigation methods
|
||||
4. **DOM Reference**: Direct reference to the `.main-content` scrolling container
|
||||
|
||||
### Navigation Type Disambiguation
|
||||
|
||||
The solution uses SvelteKit's navigation `type` to determine the appropriate scroll behavior:
|
||||
|
||||
| Navigation Type | Value | Behavior | Example |
|
||||
|----------------|-------|----------|---------|
|
||||
| Initial Load | `'enter'` | Scroll to top | First visit to the app |
|
||||
| Link Click | `'link'` | Scroll to top | Clicking `<a>` tags |
|
||||
| Programmatic | `'goto'` | Scroll to top | Using `goto()` function |
|
||||
| Browser Navigation | `'popstate'` | Restore position | Back/forward buttons |
|
||||
| Leave App | `'leave'` | N/A | Navigating away |
|
||||
|
||||
## Implementation
|
||||
|
||||
### Complete Solution Code
|
||||
|
||||
Add the following to `/src/routes/+layout.svelte`:
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import { afterNavigate, beforeNavigate } from '$app/navigation'
|
||||
import { browser } from '$app/environment'
|
||||
// ... other imports
|
||||
|
||||
// Reference to the scrolling container
|
||||
let mainContent: HTMLElement | undefined;
|
||||
|
||||
// Store scroll positions for each visited route
|
||||
const scrollPositions = new Map<string, number>();
|
||||
|
||||
// Save scroll position before navigating away
|
||||
beforeNavigate(({ from }) => {
|
||||
if (from && mainContent) {
|
||||
// Create a unique key including pathname and query params
|
||||
const key = from.url.pathname + from.url.search;
|
||||
scrollPositions.set(key, mainContent.scrollTop);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle scroll restoration or reset after navigation
|
||||
afterNavigate(({ from, to, type }) => {
|
||||
if (!mainContent) return;
|
||||
|
||||
// Use requestAnimationFrame to ensure DOM has updated
|
||||
requestAnimationFrame(() => {
|
||||
const key = to.url.pathname + to.url.search;
|
||||
|
||||
// Only restore scroll for browser back/forward navigation
|
||||
if (type === 'popstate' && scrollPositions.has(key)) {
|
||||
// User clicked back/forward button - restore their position
|
||||
mainContent.scrollTop = scrollPositions.get(key) || 0;
|
||||
} else {
|
||||
// Any other navigation type (link, goto, enter, etc.) - go to top
|
||||
mainContent.scrollTop = 0;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Optional: Export snapshot for session persistence
|
||||
export const snapshot = {
|
||||
capture: () => {
|
||||
if (!mainContent) return { scroll: 0, positions: [] };
|
||||
return {
|
||||
scroll: mainContent.scrollTop,
|
||||
positions: Array.from(scrollPositions.entries())
|
||||
};
|
||||
},
|
||||
restore: (data) => {
|
||||
if (!data || !mainContent) return;
|
||||
// Restore saved positions map
|
||||
if (data.positions) {
|
||||
scrollPositions.clear();
|
||||
data.positions.forEach(([key, value]) => {
|
||||
scrollPositions.set(key, value);
|
||||
});
|
||||
}
|
||||
// Restore current scroll position after DOM is ready
|
||||
if (browser) {
|
||||
requestAnimationFrame(() => {
|
||||
if (mainContent) mainContent.scrollTop = data.scroll;
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<!-- Update the main content element to include the reference -->
|
||||
<main class="main-content" bind:this={mainContent}>
|
||||
{@render children?.()}
|
||||
</main>
|
||||
```
|
||||
|
||||
### Integration Steps
|
||||
|
||||
1. **Import Navigation Hooks**
|
||||
```typescript
|
||||
import { afterNavigate, beforeNavigate } from '$app/navigation'
|
||||
```
|
||||
|
||||
2. **Add Container Reference**
|
||||
Change the `<main>` element to include `bind:this={mainContent}`
|
||||
|
||||
3. **Initialize Scroll Position Map**
|
||||
Create a Map to store positions: `const scrollPositions = new Map<string, number>()`
|
||||
|
||||
4. **Implement Navigation Handlers**
|
||||
Add the `beforeNavigate` and `afterNavigate` callbacks as shown above
|
||||
|
||||
5. **Optional: Add Snapshot Support**
|
||||
Export the snapshot object for session persistence across refreshes
|
||||
|
||||
## Navigation Scenarios
|
||||
|
||||
### 1. Back/Forward Button Navigation
|
||||
- **Detection**: `type === 'popstate'`
|
||||
- **Action**: Restore saved scroll position if it exists
|
||||
- **Example**: User views profile → scrolls down → clicks team → clicks back button → returns to scrolled position
|
||||
|
||||
### 2. Link Click Navigation
|
||||
- **Detection**: `type === 'link'`
|
||||
- **Action**: Reset scroll to top
|
||||
- **Example**: User clicks on any `<a>` tag or navigation link → new page starts at top
|
||||
|
||||
### 3. Page Refresh
|
||||
- **Detection**: Map is empty after refresh (unless snapshot is used)
|
||||
- **Action**: Start at top (default behavior)
|
||||
- **Example**: User refreshes browser → page loads at top
|
||||
|
||||
### 4. Programmatic Navigation
|
||||
- **Detection**: `type === 'goto'`
|
||||
- **Action**: Reset scroll to top
|
||||
- **Example**: Code calls `goto('/teams')` → page starts at top
|
||||
|
||||
### 5. Direct URL Access
|
||||
- **Detection**: `type === 'enter'`
|
||||
- **Action**: Start at top
|
||||
- **Example**: User enters URL directly or opens bookmark → page starts at top
|
||||
|
||||
## Edge Cases
|
||||
|
||||
### Scenario: Refresh Then Back
|
||||
- User refreshes page (Map is cleared)
|
||||
- User navigates back
|
||||
- Result: Scrolls to top (no stored position)
|
||||
|
||||
### Scenario: Same URL Different Navigation
|
||||
- Via link click: Always scrolls to top
|
||||
- Via back button: Restores position if available
|
||||
|
||||
### Scenario: Query Parameters
|
||||
- Positions are stored with full path + query
|
||||
- `/teams?page=2` and `/teams?page=3` have separate positions
|
||||
|
||||
### Scenario: Memory Management
|
||||
- Positions accumulate during session
|
||||
- Cleared on page refresh (unless using snapshot)
|
||||
- Consider implementing a size limit for long sessions
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Avoid `disableScrollHandling`
|
||||
The official documentation states this is "generally discouraged, since it breaks user expectations." Our solution works alongside SvelteKit's default behavior.
|
||||
|
||||
### 2. Use `requestAnimationFrame`
|
||||
Ensures the DOM has fully updated before manipulating scroll position:
|
||||
```javascript
|
||||
requestAnimationFrame(() => {
|
||||
mainContent.scrollTop = position;
|
||||
});
|
||||
```
|
||||
|
||||
### 3. Include Query Parameters in Keys
|
||||
Important for paginated views where each page should maintain its own scroll position:
|
||||
```javascript
|
||||
const key = url.pathname + url.search;
|
||||
```
|
||||
|
||||
### 4. Progressive Enhancement
|
||||
The solution gracefully degrades if JavaScript is disabled, falling back to default browser behavior.
|
||||
|
||||
### 5. Type Safety
|
||||
Use TypeScript types for better maintainability:
|
||||
```typescript
|
||||
let mainContent: HTMLElement | undefined;
|
||||
const scrollPositions = new Map<string, number>();
|
||||
```
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- [ ] Forward navigation resets scroll to top
|
||||
- [ ] Back button restores previous scroll position
|
||||
- [ ] Forward button restores appropriate position
|
||||
- [ ] Page refresh starts at top
|
||||
- [ ] Direct URL access starts at top
|
||||
- [ ] Programmatic navigation (`goto`) resets to top
|
||||
- [ ] Query parameter changes are handled correctly
|
||||
- [ ] Snapshot persistence works across refreshes (if enabled)
|
||||
- [ ] No memory leaks during long sessions
|
||||
- [ ] Works on mobile devices with touch scrolling
|
||||
|
||||
## References
|
||||
|
||||
- [SvelteKit Navigation Documentation](https://svelte.dev/docs/kit/$app-navigation)
|
||||
- [GitHub Issue #937: Customize navigation scroll container](https://github.com/sveltejs/kit/issues/937)
|
||||
- [GitHub Issue #2733: Page scroll position not reset](https://github.com/sveltejs/kit/issues/2733)
|
||||
- [GitHub Issue #9914: Get access to scroll positions](https://github.com/sveltejs/kit/issues/9914)
|
||||
- [SvelteKit Snapshots Documentation](https://kit.svelte.dev/docs/snapshots)
|
||||
|
||||
## Future Considerations
|
||||
|
||||
1. **Performance Optimization**: Implement a maximum size for the scroll positions Map to prevent memory issues in long sessions
|
||||
2. **Animation Support**: Consider smooth scrolling animations for certain navigation types
|
||||
3. **Accessibility**: Ensure screen readers properly announce page changes
|
||||
4. **Analytics**: Track scroll depth and navigation patterns for UX improvements
|
||||
5. **Configuration**: Consider making scroll behavior configurable per route
|
||||
194
docs/search-sidebar-refactor.md
Normal file
194
docs/search-sidebar-refactor.md
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
# Search Sidebar Refactor Plan (Infinite Scroll + Legacy Parity)
|
||||
|
||||
This plan upgrades `src/lib/components/panels/SearchSidebar.svelte` to support infinite-scrolling search with cancellable requests, modular components, and accessible UX. It also aligns result content with our previous app’s components so we show the most useful fields.
|
||||
|
||||
Relevant packages to install:
|
||||
https://runed.dev/docs/getting-started
|
||||
https://runed.dev/docs/utilities/resource
|
||||
|
||||
## Goals
|
||||
- Smooth, infinite-scrolling search with debounced input and request cancellation.
|
||||
- Modular, testable components (header, filters, list, items).
|
||||
- Strong accessibility and keyboard navigation.
|
||||
- Reuse legacy field choices for results (image, name, uncap, tags).
|
||||
|
||||
## Legacy Result Fields (from hensei-web)
|
||||
Reference: `../hensei-web/components/*Result`
|
||||
- Common
|
||||
- Thumbnail (grid variant), localized name.
|
||||
- `UncapIndicator` with FLB/ULB/Transcendence (stage displayed as 5 in old UI).
|
||||
- Element tag.
|
||||
- Weapons
|
||||
- Proficiency tag.
|
||||
- Summons
|
||||
- “Subaura” badge (if applicable/available from API).
|
||||
- Characters
|
||||
- Respect `special` flag for `UncapIndicator`.
|
||||
- Implementation
|
||||
- Use our image helper (`get*Image`) and placeholders; do not hardcode paths.
|
||||
|
||||
## Data Flow
|
||||
- Unified adapter: `searchResource(type, params, { signal })` wraps current resource-specific search functions and normalizes the output to `{ results, total, nextCursor? }`.
|
||||
- Debounce input: 250–300ms.
|
||||
- Cancellation: AbortController cancels prior request when query/filters/cursor change.
|
||||
- Single orchestrating effect: listens to `open`, `type`, `debouncedQuery`, and `filters`. Resets state and triggers initial fetch.
|
||||
|
||||
## Infinite Scrolling
|
||||
- IntersectionObserver sentinel at list bottom triggers `loadMore()` when visible.
|
||||
- Guards: `isLoading`, `hasMore`, `error` prevent runaway loads.
|
||||
- Use Runed's `resource()` for fetching (Svelte 5‑native) instead of TanStack Query:
|
||||
- Reactive key: `{ type, query: debouncedQuery, filters, page }`.
|
||||
- Resource fn receives `AbortSignal` for cancellation.
|
||||
- `keepPreviousValue: true` to avoid flicker on refresh.
|
||||
- Append or replace items based on `page`.
|
||||
|
||||
### Runed setup
|
||||
- Install: `pnpm add runed`
|
||||
- Import where needed: `import { resource } from 'runed'`
|
||||
- No extra config required; works seamlessly with Svelte 5 runes ($state/$derived/$effect).
|
||||
|
||||
## State Orchestration
|
||||
- State: `items[]`, `isLoading`, `error`, `hasMore`, `page`, `debouncedQuery`, `filters`, `open`.
|
||||
- Reset state on open/type/query/filters change; cancel in-flight; fetch page 1.
|
||||
- Persist last-used filters per type in localStorage and restore on open.
|
||||
- Optional lightweight cache: `Map<string, { items, page, hasMore, timestamp }>` keyed by `{ type, query, filters }` with small TTL.
|
||||
|
||||
## UX & Accessibility
|
||||
- Loading skeletons for image/title/badges.
|
||||
- Error state with Retry and helpful copy.
|
||||
- Empty state: “No results — try widening filters.”
|
||||
- Keyboard
|
||||
- Focus trap when open; restore focus to trigger on close.
|
||||
- Arrow Up/Down to move highlight; Enter to select; Escape to close.
|
||||
- `aria-live=polite` announcements for loading and result counts.
|
||||
- Performance
|
||||
- Pre-fetch next page when 60–70% scrolled (if not using IO sentinel aggressively).
|
||||
- Virtualization as a stretch goal if lists become large.
|
||||
|
||||
## Componentization
|
||||
- `SearchSidebarHeader.svelte`: search input (debounced), close button, result count.
|
||||
- `FilterGroup.svelte`: Element, Rarity, Proficiency; emits `{ element?: number[], rarity?: number[], proficiency1?: number[] }`.
|
||||
- Result items (choose either specialized components or generic + slots)
|
||||
- `WeaponResultItem.svelte`: image, name, UncapIndicator, [Element, Proficiency].
|
||||
- `SummonResultItem.svelte`: image, name, UncapIndicator, [Element, Subaura?].
|
||||
- `CharacterResultItem.svelte`: image, name, UncapIndicator(special), [Element].
|
||||
- `ResultList.svelte`: renders items, manages sentinel and keyboard focus.
|
||||
- `searchResource.ts`: adapter normalizing current API responses.
|
||||
|
||||
## Normalized API Contract
|
||||
- Input
|
||||
- `type: 'weapon' | 'character' | 'summon'`
|
||||
- `query?: string`
|
||||
- `filters: { element?: number[]; rarity?: number[]; proficiency1?: number[] }`
|
||||
- `cursor?: { page?: number; perPage?: number }` (or token-ready for future)
|
||||
- `signal: AbortSignal`
|
||||
- Output
|
||||
- `{ results: any[]; total: number; nextCursor?: { page?: number } }`
|
||||
|
||||
## Tasks (Phased)
|
||||
|
||||
### Phase 1 — Infra & UX Baseline
|
||||
- [ ] Add `searchResource` adapter + types; normalize outputs.
|
||||
- [ ] Debounce input and wire AbortController for cancellation.
|
||||
- [ ] Consolidate effects; initialize/reset state predictably.
|
||||
- [ ] Implement infinite scroll via IntersectionObserver; add guards.
|
||||
- [ ] Add skeleton, error, and empty states (minimal styles).
|
||||
|
||||
### Phase 2 — Componentization & Fields
|
||||
- [ ] Build `FilterGroup` (Element, Rarity, Proficiency) and emit filters.
|
||||
- [ ] Implement `WeaponResultItem`, `SummonResultItem`, `CharacterResultItem` with fields per legacy.
|
||||
- [ ] Extract `SearchSidebarHeader` (input, count, close) and `ResultList` (items + sentinel).
|
||||
|
||||
### Phase 3 — A11y & Keyboard
|
||||
- [ ] Add focus trap and restore focus on close.
|
||||
- [ ] Apply listbox/option roles; `aria-live` for loading/count.
|
||||
- [ ] Arrow/Enter/Escape handlers; scroll highlighted item into view.
|
||||
|
||||
### Phase 4 — Persistence & Performance
|
||||
- [ ] Persist filters per type in localStorage; hydrate on open.
|
||||
- [ ] Optional: add small in-memory cache keyed by `{ type, query, filters }` (TTL 2–5 min).
|
||||
- [ ] Optional: prefetch next page on near-end scroll.
|
||||
- [ ] Optional: list virtualization if needed.
|
||||
|
||||
## Example: resource() Outline (Runed)
|
||||
```
|
||||
// Debounce query (250–300ms)
|
||||
let query = $state('')
|
||||
let debouncedQuery = $state('')
|
||||
let debounceTimer: any
|
||||
$effect(() => {
|
||||
clearTimeout(debounceTimer)
|
||||
debounceTimer = setTimeout(() => (debouncedQuery = query.trim()), 280)
|
||||
})
|
||||
|
||||
// Paging + items
|
||||
let page = $state(1)
|
||||
let items: any[] = $state([])
|
||||
let hasMore = $state(true)
|
||||
|
||||
const params = $derived(() => ({ type, query: debouncedQuery, filters, page }))
|
||||
|
||||
import { resource } from 'runed'
|
||||
const searchRes = resource(
|
||||
params,
|
||||
async (p, ctx) => searchResource(p.type, { query: p.query, filters: p.filters, page: p.page, perPage: 20 }, { signal: ctx.signal }),
|
||||
{ keepPreviousValue: true }
|
||||
)
|
||||
|
||||
$effect(() => {
|
||||
const val = searchRes.value
|
||||
if (!val) return
|
||||
if (page === 1) items = val.results
|
||||
else items = [...items, ...val.results]
|
||||
hasMore = val.nextCursor?.page ? true : false // or derive from total_pages
|
||||
})
|
||||
|
||||
// IntersectionObserver sentinel triggers loadMore
|
||||
function onIntersect() {
|
||||
if (!searchRes.loading && hasMore) page += 1
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 5 — Tests & Polish
|
||||
- [ ] Unit tests for adapter (debounce + abort cases).
|
||||
- [ ] Interaction tests (keyboard nav, infinite scroll, retry/reload).
|
||||
- [ ] Visual QA: confirm result item content matches legacy intent.
|
||||
|
||||
## Acceptance Criteria
|
||||
- Infinite scrolling with smooth loading; no duplicate/stale requests.
|
||||
- Results show image, name, uncap, and tags mirroring legacy components.
|
||||
- Accessible: screen-reader friendly, keyboard navigable, focus managed.
|
||||
- Filters and results are modular and easily testable.
|
||||
- Caching (or local persistence) makes repeat searches feel instant.
|
||||
|
||||
## Notes / Risks
|
||||
- svelte-query adds a dependency; keep adapter thin to allow opting in later.
|
||||
- Subaura badge requires API support; if not present, hide or infer conservatively.
|
||||
- Virtualization is optional; only implement if list length causes perf issues.
|
||||
|
||||
## Extending To Teams Explore
|
||||
|
||||
The same resource-based, infinite-scroll pattern should power `src/routes/teams/explore/+page.svelte` to keep UX and tech consistent.
|
||||
|
||||
Guidelines:
|
||||
- Shared primitives
|
||||
- Reuse the resource() orchestration, IntersectionObserver sentinel, and list state (`items`, `page`, `hasMore`, `isLoading`, `error`).
|
||||
- Reuse skeleton, empty, and error components/styles for visual consistency.
|
||||
- Optional: extract a tiny `use:intersect` action and a generic `InfiniteList.svelte` wrapper.
|
||||
- Explore adapter
|
||||
- Create `exploreResource(params, { signal })` that normalizes `{ results, total, nextCursor }` from the teams listing endpoint.
|
||||
- Inputs: `query?`, `filters?` (element, tags), `sort?` (newest/popular), `page`, `perPage`.
|
||||
- SSR-friendly
|
||||
- `+page.server.ts` fetches `page=1` for SEO and first paint; client continues with infinite scroll.
|
||||
- Initialize client state from server data; enable `keepPreviousValue` to avoid flicker on hydration.
|
||||
- URL sync
|
||||
- Reflect `query`, `filters`, `sort`, and `page` in search params. On mount, hydrate state from the URL.
|
||||
- Improves shareability and back/forward navigation.
|
||||
- Cards and filters
|
||||
- Implement `TeamCard.svelte` (thumbnail, name/title, owner, likes, updatedAt, element/tags) with a matching skeleton card.
|
||||
- Build `TeamsFilterGroup.svelte` mirroring the sidebar’s `FilterGroup.svelte` experience.
|
||||
- Performance
|
||||
- Lazy-load images with `loading="lazy"` and `decoding="async"`; consider prefetching page+1 on near-end scroll.
|
||||
- Virtualization only if card density leads to perf issues.
|
||||
|
||||
By following these conventions, the search sidebar and explore page share the same mental model, enabling rapid iteration and less bespoke code per page.
|
||||
171
docs/type-migration-strategy.md
Normal file
171
docs/type-migration-strategy.md
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
# Type Migration Strategy: Existing Types vs New Architecture
|
||||
|
||||
## Current State Analysis
|
||||
|
||||
After examining the 35+ type definition files in `/src/lib/types/`, here's how they'll interact with the new architecture:
|
||||
|
||||
## Types to REPLACE
|
||||
|
||||
These types have direct conflicts with the new architecture and will be replaced:
|
||||
|
||||
### 1. Core Entity Types (Will be replaced with clean versions)
|
||||
- **Party.d.ts** → New `Party` interface
|
||||
- Current uses snake_case (e.g., `full_auto`, `charge_attack`)
|
||||
- New version will use camelCase consistently
|
||||
- Will properly type grid items with named entities
|
||||
|
||||
- **GridWeapon.d.ts** → New `GridWeapon` interface
|
||||
- Current has `object: Weapon` (matching API's naming)
|
||||
- New version will have `weapon: Weapon` (semantic naming)
|
||||
|
||||
- **GridCharacter.d.ts** → New `GridCharacter` interface
|
||||
- Current has `object: Character`
|
||||
- New version will have `character: Character`
|
||||
|
||||
- **GridSummon.d.ts** → New `GridSummon` interface
|
||||
- Current has `object: Summon`
|
||||
- New version will have `summon: Summon`
|
||||
|
||||
### 2. Redundant View Types (Will be removed entirely)
|
||||
- **From party.ts schema file:**
|
||||
- `PartyView` → Use new `Party` only
|
||||
- `GridWeaponItemView` → Use new `GridWeapon` only
|
||||
- `GridCharacterItemView` → Use new `GridCharacter` only
|
||||
- `GridSummonItemView` → Use new `GridSummon` only
|
||||
|
||||
## Types to KEEP
|
||||
|
||||
These types serve specific purposes and will remain:
|
||||
|
||||
### 1. UI State Types
|
||||
- **CheckedState.d.ts** - UI selection state
|
||||
- **ElementState.d.ts** - Element filtering state
|
||||
- **ProficiencyState.d.ts** - Proficiency filtering state
|
||||
- **RarityState.d.ts** - Rarity filtering state
|
||||
- **FilterSet.d.ts** - Filter combinations
|
||||
|
||||
### 2. Domain-Specific Types
|
||||
- **Awakening.d.ts** - Enhancement system
|
||||
- **WeaponKey.d.ts** - Weapon upgrades
|
||||
- **SimpleAxSkill.d.ts** - AX skill system
|
||||
- **ItemSkill.d.ts** - Item skills
|
||||
- **TeamElement.d.ts** - Team element logic
|
||||
|
||||
### 3. Infrastructure Types
|
||||
- **User.d.ts** - User authentication
|
||||
- **AccountCookie.d.ts** - Auth cookies
|
||||
- **UserCookie.d.ts** - User preferences
|
||||
- **GranblueCookie.d.ts** - Game data cookies
|
||||
- **AppUpdate.d.ts** - App versioning
|
||||
|
||||
### 4. Helper Types
|
||||
- **OnClickEvent.d.ts** - Event handlers
|
||||
- **MentionItem.d.ts** - Rich text mentions
|
||||
- **declarations.d.ts** - Module declarations
|
||||
- **index.d.ts** - Type exports and utilities
|
||||
|
||||
## Types to MODIFY
|
||||
|
||||
These need minor updates to work with new architecture:
|
||||
|
||||
### 1. Base Entity Types
|
||||
- **Weapon.d.ts** - Keep structure, but ensure camelCase
|
||||
- **Character.d.ts** - Keep structure, but ensure camelCase
|
||||
- **Summon.d.ts** - Keep structure, but ensure camelCase
|
||||
- **Job.d.ts** - Keep structure, but ensure camelCase
|
||||
- **JobSkill.d.ts** - Keep structure, but ensure camelCase
|
||||
- **JobAccessory.d.ts** - Keep structure, but ensure camelCase
|
||||
- **Raid.d.ts** - Keep structure, but ensure camelCase
|
||||
- **RaidGroup.d.ts** - Keep structure, but ensure camelCase
|
||||
- **Guidebook.d.ts** - Keep structure, but ensure camelCase
|
||||
|
||||
## Migration Plan
|
||||
|
||||
### Phase 1: Create New Type Definitions
|
||||
1. Create `/src/lib/types/api/` directory for new clean types
|
||||
2. Define base entities matching Rails blueprints
|
||||
3. Use consistent camelCase throughout
|
||||
4. Properly name nested entities (`weapon`, not `object`)
|
||||
|
||||
### Phase 2: Update API Client
|
||||
1. Implement automatic transformation layer in `/src/lib/api/client.ts`
|
||||
2. Handle `object` → proper entity name mapping
|
||||
3. Apply snake_case ↔ camelCase transformation
|
||||
|
||||
### Phase 3: Gradual Component Migration
|
||||
1. Update components to import from new type locations
|
||||
2. Change property access from `item.object` to `item.weapon/character/summon`
|
||||
3. Remove type casts and `as any` usage
|
||||
|
||||
### Phase 4: Cleanup
|
||||
1. Delete old conflicting type files
|
||||
2. Remove PartyView and other view types from schemas
|
||||
3. Update all imports
|
||||
|
||||
## Type Import Strategy
|
||||
|
||||
```typescript
|
||||
// OLD (current)
|
||||
import type { Party } from '$lib/types/Party'
|
||||
import type { GridWeapon } from '$lib/types/GridWeapon'
|
||||
import type { PartyView } from '$lib/api/schemas/party'
|
||||
|
||||
// NEW (after migration)
|
||||
import type { Party, GridWeapon, GridCharacter, GridSummon } from '$lib/types/api/party'
|
||||
import type { Weapon, Character, Summon } from '$lib/types/api/entities'
|
||||
// No more PartyView - just use Party
|
||||
```
|
||||
|
||||
## Benefits of This Approach
|
||||
|
||||
1. **Preserves existing work**: Keeps all UI state types, domain logic types
|
||||
2. **Single source of truth**: One `Party` type, not Party + PartyView
|
||||
3. **Type safety**: Proper TypeScript types throughout
|
||||
4. **Clean naming**: `weapon` instead of `object` everywhere
|
||||
5. **Backwards compatible**: Can migrate gradually, component by component
|
||||
|
||||
## Example Type Transformation
|
||||
|
||||
### Before (Current)
|
||||
```typescript
|
||||
// Multiple conflicting types
|
||||
interface Party { // from Party.d.ts
|
||||
full_auto: boolean
|
||||
weapons: Array<GridWeapon>
|
||||
}
|
||||
|
||||
interface GridWeapon { // from GridWeapon.d.ts
|
||||
object: Weapon // Confusing naming
|
||||
}
|
||||
|
||||
interface PartyView { // from party.ts schema
|
||||
fullAuto?: boolean
|
||||
weapons: GridWeaponItemView[]
|
||||
}
|
||||
```
|
||||
|
||||
### After (New Architecture)
|
||||
```typescript
|
||||
// Single clean type
|
||||
interface Party {
|
||||
fullAuto: boolean
|
||||
weapons: GridWeapon[]
|
||||
}
|
||||
|
||||
interface GridWeapon {
|
||||
weapon: Weapon // Semantic naming
|
||||
position: number
|
||||
mainhand?: boolean
|
||||
// ... other fields
|
||||
}
|
||||
// No PartyView needed!
|
||||
```
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. **Start with Party types** - Most critical for hydration fix
|
||||
2. **Then Grid types** - Fix the object → entity naming
|
||||
3. **Keep all other types** - They're working fine
|
||||
4. **Update components** - As needed for functionality
|
||||
|
||||
This approach minimizes disruption while fixing the core hydration and type safety issues.
|
||||
335
src/lib/api/adapters/resources/infiniteScroll.resource.svelte.ts
Normal file
335
src/lib/api/adapters/resources/infiniteScroll.resource.svelte.ts
Normal file
|
|
@ -0,0 +1,335 @@
|
|||
/**
|
||||
* Infinite Scroll Resource using Svelte 5 Runes and Runed
|
||||
*
|
||||
* Provides reactive state management for infinite scrolling with
|
||||
* automatic loading states, error handling, and viewport detection.
|
||||
*
|
||||
* @module adapters/resources/infiniteScroll
|
||||
*/
|
||||
|
||||
import { IsInViewport, watch, useDebounce } from 'runed'
|
||||
import type { AdapterError, PaginatedResponse } from '../types'
|
||||
|
||||
/**
|
||||
* Infinite scroll configuration options
|
||||
*/
|
||||
export interface InfiniteScrollOptions<T> {
|
||||
/** Function to fetch data for a given page */
|
||||
fetcher: (page: number, signal?: AbortSignal) => Promise<PaginatedResponse<T>>
|
||||
/** Initial data from SSR */
|
||||
initialData?: T[] | undefined
|
||||
/** Initial page number */
|
||||
initialPage?: number | undefined
|
||||
/** Initial total pages */
|
||||
initialTotalPages?: number | undefined
|
||||
/** Initial total count */
|
||||
initialTotal?: number | undefined
|
||||
/** Number of items per page */
|
||||
pageSize?: number | undefined
|
||||
/** Pixels before viewport edge to trigger load */
|
||||
threshold?: number | undefined
|
||||
/** Debounce delay in milliseconds */
|
||||
debounceMs?: number | undefined
|
||||
/** Maximum items to keep in memory (for performance) */
|
||||
maxItems?: number | undefined
|
||||
/** Enable debug logging */
|
||||
debug?: boolean | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a reactive infinite scroll resource for paginated data
|
||||
*
|
||||
* @example
|
||||
* ```svelte
|
||||
* <script>
|
||||
* import { createInfiniteScrollResource } from '$lib/api/adapters/resources'
|
||||
* import { partyAdapter } from '$lib/api/adapters'
|
||||
*
|
||||
* const resource = createInfiniteScrollResource({
|
||||
* fetcher: (page) => partyAdapter.list({ page }),
|
||||
* threshold: 300,
|
||||
* debounceMs: 200
|
||||
* })
|
||||
* </script>
|
||||
*
|
||||
* <InfiniteScroll {resource}>
|
||||
* <ExploreGrid items={resource.items} />
|
||||
* </InfiniteScroll>
|
||||
* ```
|
||||
*/
|
||||
export class InfiniteScrollResource<T> {
|
||||
// Reactive state
|
||||
items = $state<T[]>([])
|
||||
page = $state(1)
|
||||
totalPages = $state<number | undefined>()
|
||||
total = $state<number | undefined>()
|
||||
loading = $state(false)
|
||||
loadingMore = $state(false)
|
||||
error = $state<AdapterError | undefined>()
|
||||
|
||||
// Sentinel element for intersection detection
|
||||
sentinelElement = $state<HTMLElement | undefined>()
|
||||
|
||||
// Viewport detection using Runed
|
||||
private inViewport: IsInViewport | undefined
|
||||
|
||||
// Configuration
|
||||
private fetcher: InfiniteScrollOptions<T>['fetcher']
|
||||
private threshold: number
|
||||
private maxItems: number | undefined
|
||||
private debug: boolean
|
||||
private debouncedLoadMore: ((force?: boolean) => void) | undefined
|
||||
|
||||
// Abort controller for cancellation
|
||||
private abortController?: AbortController
|
||||
|
||||
// Track if we've initialized from SSR data
|
||||
private initialized = false
|
||||
|
||||
constructor(options: InfiniteScrollOptions<T>) {
|
||||
this.fetcher = options.fetcher
|
||||
this.threshold = options.threshold ?? 200
|
||||
this.maxItems = options.maxItems
|
||||
this.debug = options.debug ?? false
|
||||
|
||||
// Initialize with SSR data if provided
|
||||
if (options.initialData) {
|
||||
this.items = options.initialData
|
||||
this.page = options.initialPage ?? 1
|
||||
this.totalPages = options.initialTotalPages
|
||||
this.total = options.initialTotal
|
||||
this.initialized = true
|
||||
}
|
||||
|
||||
// Create debounced load function if specified
|
||||
if (options.debounceMs) {
|
||||
this.debouncedLoadMore = useDebounce(
|
||||
(force?: boolean) => this.loadMore(force),
|
||||
() => options.debounceMs!
|
||||
)
|
||||
}
|
||||
|
||||
this.log('InfiniteScrollResource initialized', {
|
||||
items: this.items.length,
|
||||
page: this.page,
|
||||
totalPages: this.totalPages
|
||||
})
|
||||
}
|
||||
|
||||
// Computed properties
|
||||
get hasMore(): boolean {
|
||||
return this.totalPages === undefined || this.page < this.totalPages
|
||||
}
|
||||
|
||||
get isEmpty(): boolean {
|
||||
return this.items.length === 0 && !this.loading
|
||||
}
|
||||
|
||||
get isLoading(): boolean {
|
||||
return this.loading || this.loadingMore
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize viewport detection after sentinel is bound
|
||||
*/
|
||||
private initViewportDetection() {
|
||||
if (this.inViewport) return
|
||||
|
||||
this.inViewport = new IsInViewport(
|
||||
() => this.sentinelElement,
|
||||
{ rootMargin: `${this.threshold}px` }
|
||||
)
|
||||
|
||||
// Watch for visibility changes
|
||||
watch(
|
||||
() => this.inViewport?.current,
|
||||
(isVisible) => {
|
||||
if (isVisible && !this.loading && !this.loadingMore && this.hasMore) {
|
||||
this.log('Sentinel visible, triggering load')
|
||||
if (this.debouncedLoadMore) {
|
||||
this.debouncedLoadMore()
|
||||
} else {
|
||||
this.loadMore()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Load initial data or reset
|
||||
*/
|
||||
async load() {
|
||||
this.reset()
|
||||
this.loading = true
|
||||
this.error = undefined
|
||||
|
||||
this.log('Loading initial data')
|
||||
|
||||
try {
|
||||
const response = await this.fetcher(1)
|
||||
this.items = response.results
|
||||
this.page = response.page
|
||||
this.totalPages = response.totalPages
|
||||
this.total = response.total
|
||||
this.initialized = true
|
||||
|
||||
this.log('Initial data loaded', {
|
||||
items: this.items.length,
|
||||
totalPages: this.totalPages
|
||||
})
|
||||
} catch (err) {
|
||||
this.error = err as AdapterError
|
||||
this.log('Error loading initial data', err)
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load next page
|
||||
*/
|
||||
async loadMore(force = false) {
|
||||
// Skip if already loading or no more pages (unless forced)
|
||||
if (!force && (!this.hasMore || this.loadingMore || this.loading)) {
|
||||
this.log('Skipping loadMore', {
|
||||
hasMore: this.hasMore,
|
||||
loadingMore: this.loadingMore,
|
||||
loading: this.loading
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
this.loadingMore = true
|
||||
this.error = undefined
|
||||
|
||||
// Cancel previous request if any
|
||||
this.abortController?.abort()
|
||||
this.abortController = new AbortController()
|
||||
|
||||
const nextPage = this.page + 1
|
||||
this.log(`Loading page ${nextPage}`)
|
||||
|
||||
try {
|
||||
const response = await this.fetcher(nextPage, this.abortController.signal)
|
||||
|
||||
// Append new items
|
||||
this.items = [...this.items, ...response.results]
|
||||
|
||||
// Trim items if max limit is set
|
||||
if (this.maxItems && this.items.length > this.maxItems) {
|
||||
const trimmed = this.items.length - this.maxItems
|
||||
this.items = this.items.slice(-this.maxItems)
|
||||
this.log(`Trimmed ${trimmed} items to stay within maxItems limit`)
|
||||
}
|
||||
|
||||
this.page = response.page
|
||||
this.totalPages = response.totalPages
|
||||
this.total = response.total
|
||||
|
||||
this.log(`Page ${nextPage} loaded`, {
|
||||
newItems: response.results.length,
|
||||
totalItems: this.items.length,
|
||||
hasMore: this.hasMore
|
||||
})
|
||||
} catch (err: any) {
|
||||
if (err.name !== 'AbortError') {
|
||||
this.error = err as AdapterError
|
||||
this.log('Error loading more', err)
|
||||
} else {
|
||||
this.log('Request aborted')
|
||||
}
|
||||
} finally {
|
||||
this.loadingMore = false
|
||||
if (this.abortController) {
|
||||
this.abortController = undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize from SSR data (for client-side hydration)
|
||||
*/
|
||||
initFromSSR(data: {
|
||||
items: T[]
|
||||
page: number
|
||||
totalPages?: number
|
||||
total?: number
|
||||
}) {
|
||||
if (this.initialized) return
|
||||
|
||||
this.items = data.items
|
||||
this.page = data.page
|
||||
this.totalPages = data.totalPages
|
||||
this.total = data.total
|
||||
this.initialized = true
|
||||
|
||||
this.log('Initialized from SSR', {
|
||||
items: this.items.length,
|
||||
page: this.page,
|
||||
totalPages: this.totalPages
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Manual trigger for load more (fallback button)
|
||||
*/
|
||||
async retry() {
|
||||
if (this.error) {
|
||||
this.log('Retrying after error')
|
||||
await this.loadMore(true)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset to initial state
|
||||
*/
|
||||
reset() {
|
||||
this.items = []
|
||||
this.page = 0
|
||||
this.totalPages = undefined
|
||||
this.total = undefined
|
||||
this.loading = false
|
||||
this.loadingMore = false
|
||||
this.error = undefined
|
||||
this.initialized = false
|
||||
this.abortController?.abort()
|
||||
this.log('Reset to initial state')
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind sentinel element
|
||||
*/
|
||||
bindSentinel(element: HTMLElement) {
|
||||
this.sentinelElement = element
|
||||
this.initViewportDetection()
|
||||
this.log('Sentinel element bound')
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup
|
||||
*/
|
||||
destroy() {
|
||||
this.abortController?.abort()
|
||||
// IsInViewport doesn't have a stop method - it cleans up automatically
|
||||
this.log('Destroyed')
|
||||
}
|
||||
|
||||
/**
|
||||
* Debug logging
|
||||
*/
|
||||
private log(message: string, data?: any) {
|
||||
if (this.debug) {
|
||||
console.log(`[InfiniteScroll] ${message}`, data ?? '')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory function for creating infinite scroll resources
|
||||
*/
|
||||
export function createInfiniteScrollResource<T>(
|
||||
options: InfiniteScrollOptions<T>
|
||||
): InfiniteScrollResource<T> {
|
||||
return new InfiniteScrollResource(options)
|
||||
}
|
||||
|
|
@ -46,7 +46,10 @@ export interface RequestOptions extends Omit<RequestInit, 'body'> {
|
|||
retries?: number
|
||||
|
||||
/** Cache duration for this request in milliseconds */
|
||||
cache?: number
|
||||
cacheTime?: number
|
||||
|
||||
/** Request cache mode */
|
||||
cache?: RequestCache
|
||||
|
||||
/** Alternative alias for cache duration */
|
||||
cacheTTL?: number
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ export class UserAdapter extends BaseAdapter {
|
|||
const params = page > 1 ? { page } : undefined
|
||||
const response = await this.request<{
|
||||
profile: UserProfile
|
||||
meta?: { count?: number; total_pages?: number; per_page?: number }
|
||||
meta?: { count?: number; total_pages?: number; totalPages?: number; per_page?: number; perPage?: number }
|
||||
}>(`/users/${encodeURIComponent(username)}`, { params })
|
||||
|
||||
const items = Array.isArray(response.profile?.parties) ? response.profile.parties : []
|
||||
|
|
@ -63,6 +63,27 @@ export class UserAdapter extends BaseAdapter {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user profile parties (for infinite scroll)
|
||||
* Returns in standard paginated format
|
||||
*/
|
||||
async getProfileParties(username: string, page = 1): Promise<{
|
||||
results: Party[]
|
||||
page: number
|
||||
total: number
|
||||
totalPages: number
|
||||
perPage: number
|
||||
}> {
|
||||
const response = await this.getProfile(username, page)
|
||||
return {
|
||||
results: response.items,
|
||||
page: response.page,
|
||||
total: response.total || 0,
|
||||
totalPages: response.totalPages || 1,
|
||||
perPage: response.perPage || 20
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's favorite parties
|
||||
*/
|
||||
|
|
|
|||
302
src/lib/components/InfiniteScroll.svelte
Normal file
302
src/lib/components/InfiniteScroll.svelte
Normal file
|
|
@ -0,0 +1,302 @@
|
|||
<script lang="ts">
|
||||
import type { InfiniteScrollResource } from '$lib/api/adapters/resources/infiniteScroll.resource.svelte'
|
||||
import type { Snippet } from 'svelte'
|
||||
|
||||
interface Props {
|
||||
resource: InfiniteScrollResource<any>
|
||||
children: Snippet
|
||||
loadingSnippet?: Snippet
|
||||
loadingMoreSnippet?: Snippet
|
||||
errorSnippet?: Snippet<[Error]>
|
||||
emptySnippet?: Snippet
|
||||
endSnippet?: Snippet
|
||||
class?: string
|
||||
}
|
||||
|
||||
const {
|
||||
resource,
|
||||
children,
|
||||
loadingSnippet,
|
||||
loadingMoreSnippet,
|
||||
errorSnippet,
|
||||
emptySnippet,
|
||||
endSnippet,
|
||||
class: className = ''
|
||||
}: Props = $props()
|
||||
|
||||
// Bind sentinel element
|
||||
let sentinel: HTMLElement
|
||||
|
||||
$effect(() => {
|
||||
if (sentinel && resource) {
|
||||
resource.bindSentinel(sentinel)
|
||||
}
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
resource?.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
// Accessibility: Announce new content to screen readers
|
||||
$effect(() => {
|
||||
if (resource.loadingMore) {
|
||||
announceToScreenReader('Loading more items...')
|
||||
}
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
if (!resource.hasMore && resource.items.length > 0) {
|
||||
announceToScreenReader('All items have been loaded')
|
||||
}
|
||||
})
|
||||
|
||||
function announceToScreenReader(message: string) {
|
||||
const announcement = document.createElement('div')
|
||||
announcement.setAttribute('role', 'status')
|
||||
announcement.setAttribute('aria-live', 'polite')
|
||||
announcement.setAttribute('aria-atomic', 'true')
|
||||
announcement.className = 'sr-only'
|
||||
announcement.textContent = message
|
||||
document.body.appendChild(announcement)
|
||||
setTimeout(() => announcement.remove(), 1000)
|
||||
}
|
||||
|
||||
function handleRetry() {
|
||||
resource.retry()
|
||||
}
|
||||
|
||||
function handleLoadMore() {
|
||||
resource.loadMore()
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="infinite-scroll-container {className}">
|
||||
<!-- Main content -->
|
||||
{#if !resource.loading}
|
||||
{@render children()}
|
||||
{/if}
|
||||
|
||||
<!-- Loading indicator for initial load -->
|
||||
{#if resource.loading}
|
||||
{#if loadingSnippet}
|
||||
{@render loadingSnippet()}
|
||||
{:else}
|
||||
<div class="loading-initial">
|
||||
<span class="spinner" aria-hidden="true"></span>
|
||||
<span>Loading...</span>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Empty state -->
|
||||
{#if resource.isEmpty && !resource.loading}
|
||||
{#if emptySnippet}
|
||||
{@render emptySnippet()}
|
||||
{:else}
|
||||
<div class="empty-state">
|
||||
<p>No items found</p>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Sentinel element for intersection observer -->
|
||||
{#if !resource.loading && resource.hasMore && resource.items.length > 0}
|
||||
<div
|
||||
bind:this={sentinel}
|
||||
class="sentinel"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
{/if}
|
||||
|
||||
<!-- Loading more indicator -->
|
||||
{#if resource.loadingMore}
|
||||
{#if loadingMoreSnippet}
|
||||
{@render loadingMoreSnippet()}
|
||||
{:else}
|
||||
<div class="loading-more" aria-busy="true">
|
||||
<span class="spinner" aria-hidden="true"></span>
|
||||
<span>Loading more...</span>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Error state with retry -->
|
||||
{#if resource.error && !resource.loadingMore}
|
||||
{#if errorSnippet}
|
||||
{@render errorSnippet(resource.error)}
|
||||
{:else}
|
||||
<div class="error-state" role="alert">
|
||||
<p>Failed to load more items</p>
|
||||
<button
|
||||
class="retry-button"
|
||||
onclick={handleRetry}
|
||||
aria-label="Retry loading items"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- End of list indicator -->
|
||||
{#if !resource.hasMore && !resource.isEmpty && !resource.loading}
|
||||
{#if endSnippet}
|
||||
{@render endSnippet()}
|
||||
{:else}
|
||||
<div class="end-state">
|
||||
<p>No more items to load</p>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Fallback load more button for accessibility -->
|
||||
{#if resource.hasMore && !resource.loadingMore && !resource.loading && resource.items.length > 0}
|
||||
<button
|
||||
class="load-more-fallback"
|
||||
onclick={handleLoadMore}
|
||||
aria-label="Load more items"
|
||||
>
|
||||
Load More
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
@use '$src/themes/spacing' as *;
|
||||
@use '$src/themes/colors' as *;
|
||||
@use '$src/themes/layout' as *;
|
||||
|
||||
.infinite-scroll-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sentinel {
|
||||
height: 1px;
|
||||
margin-top: -200px; // Trigger before reaching actual end
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.loading-initial,
|
||||
.loading-more,
|
||||
.error-state,
|
||||
.empty-state,
|
||||
.end-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: $unit-4x;
|
||||
text-align: center;
|
||||
gap: $unit;
|
||||
}
|
||||
|
||||
.loading-initial,
|
||||
.loading-more {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.error-state {
|
||||
color: var(--text-error, #dc2626);
|
||||
|
||||
p {
|
||||
margin: 0 0 $unit 0;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state,
|
||||
.end-state {
|
||||
color: var(--text-tertiary);
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.spinner {
|
||||
display: inline-block;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 3px solid rgba(0, 0, 0, 0.1);
|
||||
border-left-color: var(--primary-color, #3366ff);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
|
||||
// Respect reduced motion preference
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
animation: none;
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.retry-button {
|
||||
padding: $unit $unit-2x;
|
||||
background: var(--button-bg, #3366ff);
|
||||
color: var(--button-text, white);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: inherit;
|
||||
font-family: inherit;
|
||||
transition: opacity 0.2s;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
}
|
||||
|
||||
.load-more-fallback {
|
||||
display: block;
|
||||
margin: $unit-2x auto;
|
||||
padding: $unit $unit-2x;
|
||||
background: var(--button-bg, #f3f4f6);
|
||||
color: var(--button-text, #1f2937);
|
||||
border: 1px solid var(--button-border, #e5e7eb);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: inherit;
|
||||
font-family: inherit;
|
||||
transition: all 0.2s;
|
||||
|
||||
// Only show for keyboard/screen reader users by default
|
||||
&:not(:focus) {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--button-bg-hover, #e5e7eb);
|
||||
}
|
||||
}
|
||||
|
||||
// Screen reader only content
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,17 +1,54 @@
|
|||
<script lang="ts">
|
||||
import type { PageData } from './$types'
|
||||
import { browser } from '$app/environment'
|
||||
import InfiniteScroll from '$lib/components/InfiniteScroll.svelte'
|
||||
import ExploreGrid from '$lib/components/explore/ExploreGrid.svelte'
|
||||
import { createInfiniteScrollResource } from '$lib/api/adapters/resources/infiniteScroll.resource.svelte'
|
||||
import { userAdapter } from '$lib/api/adapters'
|
||||
import { getAvatarSrc, getAvatarSrcSet } from '$lib/utils/avatar'
|
||||
|
||||
const { data } = $props() as { data: PageData }
|
||||
const page = data.page || 1
|
||||
const totalPages = data.totalPages || undefined
|
||||
const tab = data.tab || 'teams'
|
||||
const isOwner = data.isOwner || false
|
||||
|
||||
const avatarFile = data.user?.avatar?.picture || ''
|
||||
const avatarSrc = getAvatarSrc(avatarFile)
|
||||
const avatarSrcSet = getAvatarSrcSet(avatarFile)
|
||||
|
||||
// Create infinite scroll resource for profile parties
|
||||
const profileResource = createInfiniteScrollResource({
|
||||
fetcher: async (page) => {
|
||||
if (tab === 'favorites' && isOwner) {
|
||||
const response = await userAdapter.getFavorites({ page })
|
||||
return {
|
||||
results: response.items,
|
||||
page: response.page,
|
||||
total: response.total,
|
||||
totalPages: response.totalPages,
|
||||
perPage: response.perPage
|
||||
}
|
||||
}
|
||||
return userAdapter.getProfileParties(data.user.username, page)
|
||||
},
|
||||
initialData: data.items,
|
||||
initialPage: data.page || 1,
|
||||
initialTotalPages: data.totalPages,
|
||||
initialTotal: data.total,
|
||||
threshold: 300,
|
||||
debounceMs: 200
|
||||
})
|
||||
|
||||
// Initialize with SSR data on client
|
||||
$effect(() => {
|
||||
if (browser && data.items && !profileResource.items.length) {
|
||||
profileResource.initFromSSR({
|
||||
items: data.items,
|
||||
page: data.page || 1,
|
||||
totalPages: data.totalPages,
|
||||
total: data.total
|
||||
})
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<section class="profile">
|
||||
|
|
@ -45,16 +82,27 @@
|
|||
</div>
|
||||
</header>
|
||||
|
||||
<ExploreGrid items={data.items} />
|
||||
<InfiniteScroll resource={profileResource} class="profile-grid">
|
||||
<ExploreGrid items={profileResource.items} />
|
||||
|
||||
<nav class="pagination" aria-label="Pagination">
|
||||
{#if page > 1}
|
||||
<a rel="prev" href={`?page=${page - 1}`} data-sveltekit-preload-data="hover">Previous</a>
|
||||
{/if}
|
||||
{#if totalPages && page < totalPages}
|
||||
<a rel="next" href={`?page=${page + 1}`} data-sveltekit-preload-data="hover">Next</a>
|
||||
{/if}
|
||||
</nav>
|
||||
{#snippet emptySnippet()}
|
||||
<div class="empty">
|
||||
<p>{tab === 'favorites' ? 'No favorite teams yet' : 'No teams found'}</p>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#snippet endSnippet()}
|
||||
<div class="end">
|
||||
<p>You've seen all {tab === 'favorites' ? 'favorites' : 'teams'}!</p>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#snippet errorSnippet(error)}
|
||||
<div class="error">
|
||||
<p>Failed to load {tab}: {error.message || 'Unknown error'}</p>
|
||||
</div>
|
||||
{/snippet}
|
||||
</InfiniteScroll>
|
||||
</section>
|
||||
|
||||
<style lang="scss">
|
||||
|
|
@ -97,12 +145,20 @@
|
|||
border-color: #3366ff;
|
||||
color: #3366ff;
|
||||
}
|
||||
.pagination {
|
||||
display: flex;
|
||||
gap: $unit-2x;
|
||||
padding: $unit-2x 0;
|
||||
|
||||
.empty,
|
||||
.end,
|
||||
.error {
|
||||
text-align: center;
|
||||
padding: $unit-4x;
|
||||
color: var(--text-secondary);
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
.pagination a {
|
||||
text-decoration: none;
|
||||
|
||||
.error {
|
||||
color: var(--text-error, #dc2626);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,37 @@
|
|||
<script lang="ts">
|
||||
import type { PageData } from './$types'
|
||||
import { browser } from '$app/environment'
|
||||
import InfiniteScroll from '$lib/components/InfiniteScroll.svelte'
|
||||
import ExploreGrid from '$lib/components/explore/ExploreGrid.svelte'
|
||||
import { createInfiniteScrollResource } from '$lib/api/adapters/resources/infiniteScroll.resource.svelte'
|
||||
import { partyAdapter } from '$lib/api/adapters'
|
||||
|
||||
const { data } = $props() as { data: PageData }
|
||||
|
||||
const page = data.page || 1
|
||||
const totalPages = data.totalPages || undefined
|
||||
// Create infinite scroll resource
|
||||
const exploreResource = createInfiniteScrollResource({
|
||||
fetcher: (page) => partyAdapter.list({ page }),
|
||||
initialData: data.items,
|
||||
initialPage: data.page || 1,
|
||||
initialTotalPages: data.totalPages,
|
||||
initialTotal: data.total,
|
||||
pageSize: data.perPage || 20,
|
||||
threshold: 300,
|
||||
debounceMs: 200,
|
||||
maxItems: 500 // Limit for performance
|
||||
})
|
||||
|
||||
// Initialize with SSR data on client
|
||||
$effect(() => {
|
||||
if (browser && data.items && !exploreResource.items.length) {
|
||||
exploreResource.initFromSSR({
|
||||
items: data.items,
|
||||
page: data.page || 1,
|
||||
totalPages: data.totalPages,
|
||||
total: data.total
|
||||
})
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<section class="explore">
|
||||
|
|
@ -13,23 +39,54 @@
|
|||
<h1>Explore Teams</h1>
|
||||
</header>
|
||||
|
||||
<ExploreGrid items={data.items} />
|
||||
<InfiniteScroll resource={exploreResource} class="explore-grid">
|
||||
<ExploreGrid items={exploreResource.items} />
|
||||
|
||||
<nav class="pagination" aria-label="Pagination">
|
||||
{#if page > 1}
|
||||
<a rel="prev" href={`?page=${page - 1}`} data-sveltekit-preload-data="hover">Previous</a>
|
||||
{/if}
|
||||
{#if totalPages && page < totalPages}
|
||||
<a rel="next" href={`?page=${page + 1}`} data-sveltekit-preload-data="hover">Next</a>
|
||||
{/if}
|
||||
</nav>
|
||||
{#snippet emptySnippet()}
|
||||
<div class="empty">
|
||||
<p>No teams found</p>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#snippet endSnippet()}
|
||||
<div class="end">
|
||||
<p>You've reached the end of all teams!</p>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#snippet errorSnippet(error)}
|
||||
<div class="error">
|
||||
<p>Failed to load teams: {error.message || 'Unknown error'}</p>
|
||||
</div>
|
||||
{/snippet}
|
||||
</InfiniteScroll>
|
||||
</section>
|
||||
|
||||
<style lang="scss">
|
||||
@use '$src/themes/spacing' as *;
|
||||
@use '$src/themes/colors' as *;
|
||||
|
||||
.explore { padding: $unit-2x 0; }
|
||||
h1 { margin: 0 0 $unit-2x 0; }
|
||||
.pagination { display: flex; gap: $unit-2x; padding: $unit-2x 0; }
|
||||
.pagination a { text-decoration: none; }
|
||||
.explore {
|
||||
padding: $unit-2x 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 $unit-2x 0;
|
||||
}
|
||||
|
||||
.empty,
|
||||
.end,
|
||||
.error {
|
||||
text-align: center;
|
||||
padding: $unit-4x;
|
||||
color: var(--text-secondary);
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--text-error, #dc2626);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
15
src/routes/test/+layout.svelte
Normal file
15
src/routes/test/+layout.svelte
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<script>
|
||||
// Simple layout for test pages
|
||||
</script>
|
||||
|
||||
<div class="test-layout">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.test-layout {
|
||||
min-height: 100vh;
|
||||
background: var(--background);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
</style>
|
||||
450
src/routes/test/images/+page.svelte
Normal file
450
src/routes/test/images/+page.svelte
Normal file
|
|
@ -0,0 +1,450 @@
|
|||
<script lang="ts">
|
||||
import {
|
||||
getImageUrl,
|
||||
getCharacterPose,
|
||||
type ResourceType,
|
||||
type ImageVariant
|
||||
} from '$lib/utils/images'
|
||||
|
||||
// State for selections
|
||||
let resourceType: ResourceType = $state('character')
|
||||
let variant: ImageVariant = $state('main')
|
||||
let itemId = $state('3030182000') // Gran/Djeeta as default
|
||||
let pose = $state('01')
|
||||
let uncapLevel = $state(0)
|
||||
let transcendenceStep = $state(0)
|
||||
let weaponElement = $state(0)
|
||||
let customPose = $state(false)
|
||||
|
||||
// Sample item IDs for testing
|
||||
const sampleIds = {
|
||||
character: [
|
||||
{ id: '3030182000', name: 'Gran/Djeeta (Element-specific)' },
|
||||
{ id: '3020000000', name: 'Katalina' },
|
||||
{ id: '3020001000', name: 'Rackam' },
|
||||
{ id: '3020002000', name: 'Io' },
|
||||
{ id: '3040000000', name: 'Charlotta' }
|
||||
],
|
||||
weapon: [
|
||||
{ id: '1040000000', name: 'Sword' },
|
||||
{ id: '1040001000', name: 'Luminiera Sword' },
|
||||
{ id: '1040500000', name: 'Bahamut Sword' },
|
||||
{ id: '1040019000', name: 'Opus Sword' }
|
||||
],
|
||||
summon: [
|
||||
{ id: '2040000000', name: 'Colossus' },
|
||||
{ id: '2040001000', name: 'Leviathan' },
|
||||
{ id: '2040002000', name: 'Tiamat' },
|
||||
{ id: '2040003000', name: 'Yggdrasil' }
|
||||
]
|
||||
}
|
||||
|
||||
// Available variants per resource type
|
||||
const availableVariants = $derived.by(() => {
|
||||
const base: ImageVariant[] = ['main', 'grid', 'square']
|
||||
if (resourceType === 'character') {
|
||||
return [...base, 'detail']
|
||||
} else if (resourceType === 'weapon') {
|
||||
return [...base, 'base']
|
||||
} else {
|
||||
return [...base, 'detail', 'wide']
|
||||
}
|
||||
})
|
||||
|
||||
// Auto-calculate pose based on uncap/transcendence
|
||||
const calculatedPose = $derived(
|
||||
customPose ? pose : getCharacterPose(uncapLevel, transcendenceStep)
|
||||
)
|
||||
|
||||
// Handle Gran/Djeeta element-specific poses
|
||||
const finalPose = $derived.by(() => {
|
||||
if (resourceType !== 'character') return undefined
|
||||
|
||||
let p = calculatedPose
|
||||
if (itemId === '3030182000' && weaponElement > 0) {
|
||||
p = `${p}_0${weaponElement}`
|
||||
}
|
||||
return p
|
||||
})
|
||||
|
||||
// Generated image URL
|
||||
const imageUrl = $derived(
|
||||
getImageUrl(resourceType, itemId || null, variant, {
|
||||
pose: finalPose,
|
||||
element: resourceType === 'weapon' && variant === 'grid' ? weaponElement : undefined
|
||||
})
|
||||
)
|
||||
|
||||
// File extension display
|
||||
const fileExtension = $derived.by(() => {
|
||||
if (resourceType === 'character' && variant === 'detail') return '.png'
|
||||
if (resourceType === 'weapon' && variant === 'base') return '.png'
|
||||
if (resourceType === 'summon' && variant === 'detail') return '.png'
|
||||
return '.jpg'
|
||||
})
|
||||
|
||||
// Reset variant if not available
|
||||
$effect(() => {
|
||||
if (!availableVariants.includes(variant)) {
|
||||
variant = 'main'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="test-container">
|
||||
<h1>Image Utility Test Page</h1>
|
||||
|
||||
<div class="controls">
|
||||
<section>
|
||||
<h2>Resource Type</h2>
|
||||
<div class="radio-group">
|
||||
{#each ['character', 'weapon', 'summon'] as type}
|
||||
<label>
|
||||
<input type="radio" bind:group={resourceType} value={type} />
|
||||
{type.charAt(0).toUpperCase() + type.slice(1)}
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Image Variant</h2>
|
||||
<div class="radio-group">
|
||||
{#each availableVariants as v}
|
||||
<label class:special={fileExtension === '.png' && variant === v}>
|
||||
<input type="radio" bind:group={variant} value={v} />
|
||||
{v.charAt(0).toUpperCase() + v.slice(1)}
|
||||
{#if (resourceType === 'character' && v === 'detail') || (resourceType === 'weapon' && v === 'base') || (resourceType === 'summon' && v === 'detail')}
|
||||
<span class="badge">PNG</span>
|
||||
{/if}
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Item Selection</h2>
|
||||
<div class="radio-group">
|
||||
<label>
|
||||
<input type="radio" bind:group={itemId} value="" />
|
||||
None (Placeholder)
|
||||
</label>
|
||||
{#each sampleIds[resourceType] as item}
|
||||
<label>
|
||||
<input type="radio" bind:group={itemId} value={item.id} />
|
||||
{item.name}
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="custom-id">
|
||||
<label>
|
||||
Custom ID:
|
||||
<input type="text" bind:value={itemId} placeholder="Enter Granblue ID" />
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{#if resourceType === 'character'}
|
||||
<section>
|
||||
<h2>Character Pose</h2>
|
||||
<div class="checkbox-group">
|
||||
<label>
|
||||
<input type="checkbox" bind:checked={customPose} />
|
||||
Manual pose control
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{#if customPose}
|
||||
<div class="radio-group">
|
||||
{#each ['01', '02', '03', '04'] as p}
|
||||
<label>
|
||||
<input type="radio" bind:group={pose} value={p} />
|
||||
Pose {p}
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="slider-group">
|
||||
<label>
|
||||
Uncap Level: {uncapLevel}
|
||||
<input type="range" bind:value={uncapLevel} min="0" max="6" />
|
||||
</label>
|
||||
<label>
|
||||
Transcendence: {transcendenceStep}
|
||||
<input type="range" bind:value={transcendenceStep} min="0" max="5" />
|
||||
</label>
|
||||
<div class="info">Calculated Pose: {calculatedPose}</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if itemId === '3030182000'}
|
||||
<div class="element-group">
|
||||
<h3>Gran/Djeeta Element</h3>
|
||||
<div class="radio-group">
|
||||
{#each [{ value: 0, label: 'None' }, { value: 1, label: 'Wind' }, { value: 2, label: 'Fire' }, { value: 3, label: 'Water' }, { value: 4, label: 'Earth' }, { value: 5, label: 'Dark' }, { value: 6, label: 'Light' }] as elem}
|
||||
<label>
|
||||
<input type="radio" bind:group={weaponElement} value={elem.value} />
|
||||
{elem.label}
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if resourceType === 'weapon' && variant === 'grid'}
|
||||
<section>
|
||||
<h2>Weapon Element (Grid Only)</h2>
|
||||
<div class="radio-group">
|
||||
{#each [{ value: 0, label: 'Default' }, { value: 1, label: 'Wind' }, { value: 2, label: 'Fire' }, { value: 3, label: 'Water' }, { value: 4, label: 'Earth' }, { value: 5, label: 'Dark' }, { value: 6, label: 'Light' }] as elem}
|
||||
<label>
|
||||
<input type="radio" bind:group={weaponElement} value={elem.value} />
|
||||
{elem.label}
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="output">
|
||||
<section class="url-display">
|
||||
<h2>Generated URL</h2>
|
||||
<code>{imageUrl}</code>
|
||||
<div class="path-info">
|
||||
<span>Directory: <strong>{resourceType}-{variant}</strong></span>
|
||||
<span>Extension: <strong>{fileExtension}</strong></span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="image-display">
|
||||
<h2>Image Preview</h2>
|
||||
<div class="image-container" data-variant={variant}>
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt="Test image"
|
||||
on:error={(e) => {
|
||||
e.currentTarget.classList.add('error')
|
||||
}}
|
||||
on:load={(e) => {
|
||||
e.currentTarget.classList.remove('error')
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p class="note">Note: Image will show error state if file doesn't exist</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
@use '$src/themes/spacing' as *;
|
||||
@use '$src/themes/colors' as *;
|
||||
@use '$src/themes/typography' as *;
|
||||
@use '$src/themes/layout' as *;
|
||||
|
||||
.test-container {
|
||||
padding: $unit-2x;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-bottom: $unit-3x;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: $font-large;
|
||||
margin-bottom: $unit;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: $font-regular;
|
||||
margin-bottom: $unit-half;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: $unit-2x;
|
||||
margin-bottom: $unit-3x;
|
||||
}
|
||||
|
||||
section {
|
||||
background: var(--background-secondary, $grey-90);
|
||||
border: 1px solid var(--border-color, $grey-80);
|
||||
border-radius: $card-corner;
|
||||
padding: $unit-2x;
|
||||
}
|
||||
|
||||
.radio-group,
|
||||
.checkbox-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit-half;
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $unit-half;
|
||||
cursor: pointer;
|
||||
padding: $unit-half;
|
||||
border-radius: $item-corner-small;
|
||||
transition: background-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: var(--background-hover, rgba(255, 255, 255, 0.05));
|
||||
}
|
||||
|
||||
&.special {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
border: 1px solid rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
input {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: $font-tiny;
|
||||
padding: 2px 6px;
|
||||
background: rgba(59, 130, 246, 0.2);
|
||||
color: rgb(59, 130, 246);
|
||||
border-radius: 4px;
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.custom-id {
|
||||
margin-top: $unit;
|
||||
padding-top: $unit;
|
||||
border-top: 1px solid var(--border-color, $grey-80);
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit-half;
|
||||
}
|
||||
|
||||
input[type='text'] {
|
||||
padding: $unit-half $unit;
|
||||
background: var(--input-bg, $grey-95);
|
||||
border: 1px solid var(--border-color, $grey-80);
|
||||
border-radius: $input-corner;
|
||||
color: var(--text-primary);
|
||||
font-family: monospace;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-blue, #3b82f6);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.slider-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit;
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit-half;
|
||||
}
|
||||
|
||||
input[type='range'] {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.info {
|
||||
padding: $unit-half;
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
border-radius: $item-corner-small;
|
||||
color: var(--text-primary);
|
||||
font-weight: $medium;
|
||||
}
|
||||
}
|
||||
|
||||
.element-group {
|
||||
margin-top: $unit;
|
||||
padding-top: $unit;
|
||||
border-top: 1px solid var(--border-color, $grey-80);
|
||||
}
|
||||
|
||||
.output {
|
||||
display: grid;
|
||||
gap: $unit-2x;
|
||||
}
|
||||
|
||||
.url-display {
|
||||
code {
|
||||
display: block;
|
||||
padding: $unit;
|
||||
background: var(--code-bg, $grey-95);
|
||||
border-radius: $item-corner-small;
|
||||
font-family: monospace;
|
||||
font-size: $font-small;
|
||||
word-break: break-all;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: $unit;
|
||||
}
|
||||
|
||||
.path-info {
|
||||
display: flex;
|
||||
gap: $unit-2x;
|
||||
font-size: $font-small;
|
||||
color: var(--text-secondary);
|
||||
|
||||
strong {
|
||||
color: var(--text-primary);
|
||||
font-family: monospace;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.image-display {
|
||||
.image-container {
|
||||
background: $grey-95;
|
||||
border: 2px dashed $grey-80;
|
||||
border-radius: $card-corner;
|
||||
padding: $unit-2x;
|
||||
min-height: 200px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&[data-variant='detail'],
|
||||
&[data-variant='base'] {
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
&[data-variant='wide'] {
|
||||
min-height: 150px;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
border-radius: $item-corner;
|
||||
|
||||
&.error {
|
||||
opacity: 0.3;
|
||||
filter: grayscale(1);
|
||||
border: 2px solid red;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.note {
|
||||
margin-top: $unit;
|
||||
font-size: $font-small;
|
||||
color: var(--text-secondary);
|
||||
font-style: italic;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Loading…
Reference in a new issue