add infinite scrolling to explore and profile pages

This commit is contained in:
Justin Edmund 2025-09-24 22:24:53 -07:00
parent 8f6a8ac522
commit 999f03f42c
21 changed files with 7453 additions and 34 deletions

View 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.

File diff suppressed because it is too large Load diff

211
docs/api-refactor-plan.md Normal file
View 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
View 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

View 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 schemas `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.

View 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*

View 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
View 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

File diff suppressed because it is too large Load diff

View 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.

View 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

View 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 apps 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: 250300ms.
- 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 5native) 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 6070% 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 25 min).
- [ ] Optional: prefetch next page on near-end scroll.
- [ ] Optional: list virtualization if needed.
## Example: resource() Outline (Runed)
```
// Debounce query (250300ms)
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 sidebars `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.

View 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.

View 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)
}

View file

@ -46,7 +46,10 @@ export interface RequestOptions extends Omit<RequestInit, 'body'> {
retries?: number
/** Cache duration for this request in milliseconds */
cache?: number
cacheTime?: number
/** Request cache mode */
cache?: RequestCache
/** Alternative alias for cache duration */
cacheTTL?: number

View file

@ -48,7 +48,7 @@ export class UserAdapter extends BaseAdapter {
const params = page > 1 ? { page } : undefined
const response = await this.request<{
profile: UserProfile
meta?: { count?: number; total_pages?: number; per_page?: number }
meta?: { count?: number; total_pages?: number; totalPages?: number; per_page?: number; perPage?: number }
}>(`/users/${encodeURIComponent(username)}`, { params })
const items = Array.isArray(response.profile?.parties) ? response.profile.parties : []
@ -63,6 +63,27 @@ export class UserAdapter extends BaseAdapter {
}
}
/**
* Get user profile parties (for infinite scroll)
* Returns in standard paginated format
*/
async getProfileParties(username: string, page = 1): Promise<{
results: Party[]
page: number
total: number
totalPages: number
perPage: number
}> {
const response = await this.getProfile(username, page)
return {
results: response.items,
page: response.page,
total: response.total || 0,
totalPages: response.totalPages || 1,
perPage: response.perPage || 20
}
}
/**
* Get user's favorite parties
*/

View 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>

View file

@ -1,17 +1,54 @@
<script lang="ts">
import type { PageData } from './$types'
import { browser } from '$app/environment'
import InfiniteScroll from '$lib/components/InfiniteScroll.svelte'
import ExploreGrid from '$lib/components/explore/ExploreGrid.svelte'
import { createInfiniteScrollResource } from '$lib/api/adapters/resources/infiniteScroll.resource.svelte'
import { userAdapter } from '$lib/api/adapters'
import { getAvatarSrc, getAvatarSrcSet } from '$lib/utils/avatar'
const { data } = $props() as { data: PageData }
const page = data.page || 1
const totalPages = data.totalPages || undefined
const tab = data.tab || 'teams'
const isOwner = data.isOwner || false
const avatarFile = data.user?.avatar?.picture || ''
const avatarSrc = getAvatarSrc(avatarFile)
const avatarSrcSet = getAvatarSrcSet(avatarFile)
// Create infinite scroll resource for profile parties
const profileResource = createInfiniteScrollResource({
fetcher: async (page) => {
if (tab === 'favorites' && isOwner) {
const response = await userAdapter.getFavorites({ page })
return {
results: response.items,
page: response.page,
total: response.total,
totalPages: response.totalPages,
perPage: response.perPage
}
}
return userAdapter.getProfileParties(data.user.username, page)
},
initialData: data.items,
initialPage: data.page || 1,
initialTotalPages: data.totalPages,
initialTotal: data.total,
threshold: 300,
debounceMs: 200
})
// Initialize with SSR data on client
$effect(() => {
if (browser && data.items && !profileResource.items.length) {
profileResource.initFromSSR({
items: data.items,
page: data.page || 1,
totalPages: data.totalPages,
total: data.total
})
}
})
</script>
<section class="profile">
@ -45,16 +82,27 @@
</div>
</header>
<ExploreGrid items={data.items} />
<InfiniteScroll resource={profileResource} class="profile-grid">
<ExploreGrid items={profileResource.items} />
<nav class="pagination" aria-label="Pagination">
{#if page > 1}
<a rel="prev" href={`?page=${page - 1}`} data-sveltekit-preload-data="hover">Previous</a>
{/if}
{#if totalPages && page < totalPages}
<a rel="next" href={`?page=${page + 1}`} data-sveltekit-preload-data="hover">Next</a>
{/if}
</nav>
{#snippet emptySnippet()}
<div class="empty">
<p>{tab === 'favorites' ? 'No favorite teams yet' : 'No teams found'}</p>
</div>
{/snippet}
{#snippet endSnippet()}
<div class="end">
<p>You've seen all {tab === 'favorites' ? 'favorites' : 'teams'}!</p>
</div>
{/snippet}
{#snippet errorSnippet(error)}
<div class="error">
<p>Failed to load {tab}: {error.message || 'Unknown error'}</p>
</div>
{/snippet}
</InfiniteScroll>
</section>
<style lang="scss">
@ -97,12 +145,20 @@
border-color: #3366ff;
color: #3366ff;
}
.pagination {
display: flex;
gap: $unit-2x;
padding: $unit-2x 0;
.empty,
.end,
.error {
text-align: center;
padding: $unit-4x;
color: var(--text-secondary);
p {
margin: 0;
}
}
.pagination a {
text-decoration: none;
.error {
color: var(--text-error, #dc2626);
}
</style>

View file

@ -1,11 +1,37 @@
<script lang="ts">
import type { PageData } from './$types'
import { browser } from '$app/environment'
import InfiniteScroll from '$lib/components/InfiniteScroll.svelte'
import ExploreGrid from '$lib/components/explore/ExploreGrid.svelte'
import { createInfiniteScrollResource } from '$lib/api/adapters/resources/infiniteScroll.resource.svelte'
import { partyAdapter } from '$lib/api/adapters'
const { data } = $props() as { data: PageData }
const page = data.page || 1
const totalPages = data.totalPages || undefined
// Create infinite scroll resource
const exploreResource = createInfiniteScrollResource({
fetcher: (page) => partyAdapter.list({ page }),
initialData: data.items,
initialPage: data.page || 1,
initialTotalPages: data.totalPages,
initialTotal: data.total,
pageSize: data.perPage || 20,
threshold: 300,
debounceMs: 200,
maxItems: 500 // Limit for performance
})
// Initialize with SSR data on client
$effect(() => {
if (browser && data.items && !exploreResource.items.length) {
exploreResource.initFromSSR({
items: data.items,
page: data.page || 1,
totalPages: data.totalPages,
total: data.total
})
}
})
</script>
<section class="explore">
@ -13,23 +39,54 @@
<h1>Explore Teams</h1>
</header>
<ExploreGrid items={data.items} />
<InfiniteScroll resource={exploreResource} class="explore-grid">
<ExploreGrid items={exploreResource.items} />
<nav class="pagination" aria-label="Pagination">
{#if page > 1}
<a rel="prev" href={`?page=${page - 1}`} data-sveltekit-preload-data="hover">Previous</a>
{/if}
{#if totalPages && page < totalPages}
<a rel="next" href={`?page=${page + 1}`} data-sveltekit-preload-data="hover">Next</a>
{/if}
</nav>
{#snippet emptySnippet()}
<div class="empty">
<p>No teams found</p>
</div>
{/snippet}
{#snippet endSnippet()}
<div class="end">
<p>You've reached the end of all teams!</p>
</div>
{/snippet}
{#snippet errorSnippet(error)}
<div class="error">
<p>Failed to load teams: {error.message || 'Unknown error'}</p>
</div>
{/snippet}
</InfiniteScroll>
</section>
<style lang="scss">
@use '$src/themes/spacing' as *;
@use '$src/themes/colors' as *;
.explore { padding: $unit-2x 0; }
h1 { margin: 0 0 $unit-2x 0; }
.pagination { display: flex; gap: $unit-2x; padding: $unit-2x 0; }
.pagination a { text-decoration: none; }
.explore {
padding: $unit-2x 0;
}
h1 {
margin: 0 0 $unit-2x 0;
}
.empty,
.end,
.error {
text-align: center;
padding: $unit-4x;
color: var(--text-secondary);
p {
margin: 0;
}
}
.error {
color: var(--text-error, #dc2626);
}
</style>

View 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>

View 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>