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
|
retries?: number
|
||||||
|
|
||||||
/** Cache duration for this request in milliseconds */
|
/** Cache duration for this request in milliseconds */
|
||||||
cache?: number
|
cacheTime?: number
|
||||||
|
|
||||||
|
/** Request cache mode */
|
||||||
|
cache?: RequestCache
|
||||||
|
|
||||||
/** Alternative alias for cache duration */
|
/** Alternative alias for cache duration */
|
||||||
cacheTTL?: number
|
cacheTTL?: number
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ export class UserAdapter extends BaseAdapter {
|
||||||
const params = page > 1 ? { page } : undefined
|
const params = page > 1 ? { page } : undefined
|
||||||
const response = await this.request<{
|
const response = await this.request<{
|
||||||
profile: UserProfile
|
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 })
|
}>(`/users/${encodeURIComponent(username)}`, { params })
|
||||||
|
|
||||||
const items = Array.isArray(response.profile?.parties) ? response.profile.parties : []
|
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
|
* 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">
|
<script lang="ts">
|
||||||
import type { PageData } from './$types'
|
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 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'
|
import { getAvatarSrc, getAvatarSrcSet } from '$lib/utils/avatar'
|
||||||
|
|
||||||
const { data } = $props() as { data: PageData }
|
const { data } = $props() as { data: PageData }
|
||||||
const page = data.page || 1
|
|
||||||
const totalPages = data.totalPages || undefined
|
|
||||||
const tab = data.tab || 'teams'
|
const tab = data.tab || 'teams'
|
||||||
const isOwner = data.isOwner || false
|
const isOwner = data.isOwner || false
|
||||||
|
|
||||||
const avatarFile = data.user?.avatar?.picture || ''
|
const avatarFile = data.user?.avatar?.picture || ''
|
||||||
const avatarSrc = getAvatarSrc(avatarFile)
|
const avatarSrc = getAvatarSrc(avatarFile)
|
||||||
const avatarSrcSet = getAvatarSrcSet(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>
|
</script>
|
||||||
|
|
||||||
<section class="profile">
|
<section class="profile">
|
||||||
|
|
@ -45,16 +82,27 @@
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<ExploreGrid items={data.items} />
|
<InfiniteScroll resource={profileResource} class="profile-grid">
|
||||||
|
<ExploreGrid items={profileResource.items} />
|
||||||
|
|
||||||
<nav class="pagination" aria-label="Pagination">
|
{#snippet emptySnippet()}
|
||||||
{#if page > 1}
|
<div class="empty">
|
||||||
<a rel="prev" href={`?page=${page - 1}`} data-sveltekit-preload-data="hover">Previous</a>
|
<p>{tab === 'favorites' ? 'No favorite teams yet' : 'No teams found'}</p>
|
||||||
{/if}
|
</div>
|
||||||
{#if totalPages && page < totalPages}
|
{/snippet}
|
||||||
<a rel="next" href={`?page=${page + 1}`} data-sveltekit-preload-data="hover">Next</a>
|
|
||||||
{/if}
|
{#snippet endSnippet()}
|
||||||
</nav>
|
<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>
|
</section>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
|
|
@ -97,12 +145,20 @@
|
||||||
border-color: #3366ff;
|
border-color: #3366ff;
|
||||||
color: #3366ff;
|
color: #3366ff;
|
||||||
}
|
}
|
||||||
.pagination {
|
|
||||||
display: flex;
|
.empty,
|
||||||
gap: $unit-2x;
|
.end,
|
||||||
padding: $unit-2x 0;
|
.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>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,37 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { PageData } from './$types'
|
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 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 { data } = $props() as { data: PageData }
|
||||||
|
|
||||||
const page = data.page || 1
|
// Create infinite scroll resource
|
||||||
const totalPages = data.totalPages || undefined
|
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>
|
</script>
|
||||||
|
|
||||||
<section class="explore">
|
<section class="explore">
|
||||||
|
|
@ -13,23 +39,54 @@
|
||||||
<h1>Explore Teams</h1>
|
<h1>Explore Teams</h1>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<ExploreGrid items={data.items} />
|
<InfiniteScroll resource={exploreResource} class="explore-grid">
|
||||||
|
<ExploreGrid items={exploreResource.items} />
|
||||||
|
|
||||||
<nav class="pagination" aria-label="Pagination">
|
{#snippet emptySnippet()}
|
||||||
{#if page > 1}
|
<div class="empty">
|
||||||
<a rel="prev" href={`?page=${page - 1}`} data-sveltekit-preload-data="hover">Previous</a>
|
<p>No teams found</p>
|
||||||
{/if}
|
</div>
|
||||||
{#if totalPages && page < totalPages}
|
{/snippet}
|
||||||
<a rel="next" href={`?page=${page + 1}`} data-sveltekit-preload-data="hover">Next</a>
|
|
||||||
{/if}
|
{#snippet endSnippet()}
|
||||||
</nav>
|
<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>
|
</section>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
@use '$src/themes/spacing' as *;
|
@use '$src/themes/spacing' as *;
|
||||||
|
@use '$src/themes/colors' as *;
|
||||||
|
|
||||||
.explore { padding: $unit-2x 0; }
|
.explore {
|
||||||
h1 { margin: 0 0 $unit-2x 0; }
|
padding: $unit-2x 0;
|
||||||
.pagination { display: flex; gap: $unit-2x; padding: $unit-2x 0; }
|
}
|
||||||
.pagination a { text-decoration: none; }
|
|
||||||
|
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>
|
</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